← Back to list

Winston + CloudWatch: Logging Smartly Without Killing Performance

Logging is one of the most underrated performance killers in Node.js applications.

Shubham Soni · 2026-01-29 06:03 · 1 claps · 2.6 min read
#winston #cloudwatch #logger #logging #loglevel
Open on Medium ↗
Wiki topics: 🌐 · Web Development

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

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 → allowed
  • debug → 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 false avoids 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