Lessons Learned: How Console Logging Nearly Broke My Node.js Production System

2026-06-13

Introduction

When building backend systems with Node.js, logging is often treated as a trivial concern—something you sprinkle in for debugging and remove later.

That assumption cost me production stability.

What initially looked like a memory leak in business logic turned out to be a logging + I/O backpressure problem rooted in Node.js internals, specifically around stdout behavior, synchronous writes, and event loop saturation under load.

This article breaks down:

  • What actually happened in production
  • Why console.log became a hidden bottleneck
  • How Node.js stream backpressure plays a role
  • Why switching to structured logging (Winston) fixed the system
  • Key architectural lessons for production-grade logging

1. The Symptom: “Memory Leak” That Wasn’t a Leak

The system behaved like this:

  • Application runs normally after deployment
  • After days/weeks → memory usage grows
  • Response latency increases
  • Eventually instability → server restart
  • Cycle repeats

Initial assumption:

“We have a memory leak somewhere in business logic.”

But code review showed:

  • No unbounded caches
  • No retained closures
  • No obvious global state leaks

So the suspicion shifted outward.


2. The Hidden Actor: Logging Everywhere

During early development, the codebase heavily used:

console.log("Request received", req.body);
console.debug("User payload", user);

And worse:

  • Logging inside hot request paths
  • Logging large nested objects
  • Logging every iteration in loops
  • Debug logs left in production builds

At low traffic → harmless At production load → catastrophic behavior emerges


3. What console.log Actually Does in Node.js

In Node.js, console.log is not “just a print statement”.

It is effectively:

console.log()
  → process.stdout.write()
      → Writable Stream (sync/async behavior depending on TTY)
          → OS syscalls (write)

Key Insight

process.stdout is a stream, not a fire-and-forget sink.


Simplified Flow Diagram

[Application Code]
        ↓
   console.log()
        ↓
 process.stdout.write()
        ↓
 Node.js Writable Stream
        ↓
 OS Kernel Buffer
        ↓
 Disk / Terminal / Docker Logs

4. Where Things Start Breaking: Backpressure

Node.js streams implement backpressure control.

When the internal buffer fills:

  • write() returns false
  • Node is supposed to pause writes until "drain" event

But here is the problem:

console.log ignores backpressure semantics in practice

When logging is frequent:

  • stdout buffer fills
  • Node keeps writing anyway (or queues heavily)
  • memory grows due to buffered writes
  • event loop gets pressured by I/O churn

Backpressure Diagram

App → console.log flood
            ↓
   stdout buffer fills
            ↓
   write() starts returning false
            ↓
   backlog accumulates in memory
            ↓
   event loop slows down
            ↓
   latency + memory spike

5. Why It Looked Like a Memory Leak

The confusion came from:

1. Buffered logs accumulating in memory

Node maintains internal buffers for stream writes.

2. High object serialization cost

Logging large objects means:

  • JSON serialization cost
  • Deep traversal cost
  • Temporary memory spikes

3. Event loop starvation

Heavy logging competes with:

  • request handling
  • GC cycles
  • async callbacks

Result: system “feels” like memory leak


6. Why Removing Logs “Fixed” the Issue

When logs were removed:

  • stdout pressure disappeared
  • event loop stabilized
  • GC regained breathing room
  • memory usage flattened

This created a false conclusion:

“console.log caused memory leak”

More accurate statement:

“Excessive synchronous logging under high load created I/O and memory pressure that mimicked a leak.”


7. The Proper Fix: Structured Logging with Winston

We replaced raw logging with Winston:

Why Winston helped

  • Asynchronous transports
  • Log level control
  • JSON structured output
  • File rotation / external sinks
  • Reduced hot-path logging

Example Winston Setup

/**
 * @file src/infrastructure/logger/winston.logger.ts
 */

import winston from "winston";

export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || "info",
  format: winston.format.json(),
  transports: [
    new winston.transports.Console({
      handleExceptions: true,
    }),
    new winston.transports.File({
      filename: "logs/app.log",
    }),
  ],
});

Usage in Service Layer

/**
 * @file src/features/auth/application/auth.service.ts
 */

import { logger } from "@/infrastructure/logger/winston.logger";

export function signIn(userId: string) {
  logger.info("User sign-in attempt", { userId });

  // business logic...
}

8. Architectural Lessons Learned

1. Logging is a subsystem, not a utility

Treat logging as:

  • Rate-sensitive
  • I/O-bound
  • Infrastructure-level concern

Not:

“just console statements”


2. Never log large objects in hot paths

Avoid:

console.log(req);

Prefer:

logger.info("Request received", {
  path: req.path,
  method: req.method,
});

3. Understand Node.js stream backpressure

If your app writes to stdout heavily:

  • You are interacting with a stream
  • You are subject to OS-level buffering
  • You are not “just printing logs”

4. Production ≠ Development logging

Development:

  • verbose logging is fine

Production:

  • structured logging
  • controlled log levels
  • external log aggregation

5. Logging can become a performance multiplier

At scale:

1 request → 5 logs
10k requests/sec → 50k logs/sec

That becomes:

  • CPU overhead
  • memory pressure
  • I/O contention

9. Final Takeaway

What initially looked like a mysterious memory leak turned out to be a system-level pressure issue caused by uncontrolled logging behavior in Node.js streams.

The real fix wasn’t removing logs—it was engineering a logging strategy suitable for production workloads.