Winston + CloudWatch: Logging Smartly Without Killing Performance
Logging is one of the most underrated performance killers in Node.js applications.
Winston + CloudWatch: Logging Smartly Without Killing Performance
Logging is one of the most underrated performance killers in Node.js applications.
When we use Winston with AWS CloudWatch, we often assume logs are “cheap” because they’re just console output. In reality, stringification, serialization, and transport costs add up fast — especially for debug logs carrying large payloads.

Winston+Cloudwatch
This post walks through:
- How Winston evaluates log levels internally
- Why unnecessary JSON.stringify is expensive
- How CloudWatch auto-parses structured logs
- A clean pattern to skip stringify work entirely for disabled log levels
- Small code improvements for better performance and readability
The Common Setup (and the Hidden Cost)
A typical Winston + CloudWatch setup looks like this:
import { createLogger, format, transports, config } from 'winston';
const { combine, timestamp } = format;
const logLevel = process.env.LOG_LEVEL;
const logger = createLogger({
level: logLevel,
format: combine(
timestamp(),
format.json()
),
transports: [new transports.Console()],
});
logger.debug({
message: "Debug log",
data: { response }
});
Looks harmless, right?
The problem 👇
Even if LOG_LEVEL=info, the debug log object is still created, and Winston still evaluates it before deciding to drop it.
For small payloads, you won’t notice. For large objects (API responses, DB results, request contexts), this becomes real CPU overhead.
How Winston Log Levels Actually Work
Winston assigns numeric values to log levels.
For syslog levels (used internally):
emerg: 0
alert: 1
crit: 2
error: 3
warning: 4
notice: 5
info: 6
debug: 7
If your configured level is info, then:
info,warn,error→ alloweddebug→ should be ignored
But Winston still receives the log entry unless you explicitly short-circuit it.
Skipping Logs Before JSON Stringification
This is the key optimization.
We can add a custom format filter that drops logs before they go through format.json().
format((info) => {
if (config.syslog.levels[info.level] > config.syslog.levels[logLevel]) {
return false; // stop processing completely
}
return info;
})();
Why this matters
format.json()performs deep serialization- Large objects → expensive
JSON.stringify - Returning
falseavoids all downstream formatting
Final Optimized Logger Setup
Here’s a cleaned-up and production-friendly version of your logger:
import { createLogger, format, transports, config } from 'winston';
const { combine, timestamp, json } = format;
const logLevel = process.env.LOG_LEVEL ?? 'info';
export const logger = createLogger({
level: logLevel,
format: combine(
// Drop logs early if level is disabled
format((info) => {
if (config.syslog.levels[info.level] > config.syslog.levels[logLevel]) {
return false;
}
return info;
})(),
timestamp(),
json()
),
defaultMeta: { service: 'user-service' },
transports: [new transports.Console()],
});
CloudWatch Auto-Parsing: Let JSON Be JSON
One mistake teams make is manually stringifying logs:
logger.info(JSON.stringify({
message: "Info log",
data: response
}));
❌ Don’t do this
CloudWatch treats this as one big string, which means:
- No structured fields
- No
@message.data.response - Painful Logs Insights queries
The Right Way: Structured Logs
logger.info({
message: "Info log",
data: { response }
});
Because we’re using format.json():
- Winston emits a valid JSON object
- CloudWatch automatically parses it
- Each property becomes queryable
Example CloudWatch Insights query
fields @timestamp, message, data.response
| sort @timestamp desc
| limit 20
No custom parsing needed. Clean. Fast. Powerful.
Why Skipping Debug Logs Improves Performance (A Lot)
In production:
- Debug logs are usually disabled
- Payloads are usually large
- Traffic is high
What you save by skipping stringify
- CPU cycles
- Garbage collection pressure
- Lambda execution time
- Container CPU throttling
This becomes especially important when:
- Logging API responses
- Logging DB query results
- Logging event payloads (SQS, EventBridge, Webhooks)
Logging shouldn’t become your bottleneck.
A Practical Rule of Thumb
Debug logs
- Meant for humans
- Can include large payloads
- Should be cheap when disabled
Info logs
- Meant for observability
- Should stay structured
- Should always be queryable in CloudWatch
Final Takeaway
Logging is part of your hot path — treat it like production code.
By:
- Dropping disabled logs early
- Avoiding unnecessary
JSON.stringify - Letting CloudWatch auto-parse structured logs
You get:
- Better performance
- Cleaner logs
- Easier debugging
- Happier CPUs 😄
메타데이터
- post_id
- a1a35213aece
- slug
- winston-cloudwatch-logging-smartly-without-killing-performance-a1a35213aece
- url
- https://medium.com/@sonishubham65/winston-cloudwatch-logging-smartly-without-killing-performance-a1a35213aece
- canonical_url
- https://medium.com/@sonishubham65/winston-cloudwatch-logging-smartly-without-killing-performance-a1a35213aece
- author_url
- https://medium.com/@sonishubham65
- status
- ok
- fetched_at
- 2026-07-13 06:23:13