Node.js
Lessons Learned: How Console Logging Nearly Broke My Node.js Production System
A production incident that initially appeared to be a memory leak turned out to be a logging and I/O backpressure problem. This article explores how excessive console logging affected memory usage, event loop responsiveness, and overall system stability in a Node.js application.
By Ishan Wickremasuriya
Published 2026-06-13 • 9 min read
Introduction
Every production incident starts with a symptom that points somewhere wrong.
This one pointed at a memory leak. It took days of investigation to discover that the real culprit was something every Node.js developer writes without thinking twice: console.log.
This is the story of how uncontrolled logging under production load created I/O backpressure, saturated the event loop, and caused a system to slowly degrade in a way that looked exactly like a business logic memory leak — until we understood what was actually happening at the runtime level.
The Symptom: A "Memory Leak" That Wasn't a Leak
The pattern was consistent and maddening:
- Application deploys cleanly
- Runs normally for days or weeks
- Memory usage begins a slow, steady climb
- Response latency starts to increase
- Eventually: instability, server restart, temporary relief
- The cycle repeats
The immediate assumption was a memory leak somewhere in business logic.
Code review showed none of the usual suspects:
✗ No unbounded caches
✗ No retained closures
✗ No obvious global state accumulation
✗ No event listeners left unremoved
The code looked clean. The problem kept coming back.
So we looked elsewhere.
The Hidden Actor: Logging Everywhere
During development, the codebase had accumulated a significant amount of logging:
console.log("Request received", req.body);
console.debug("User payload", user);
console.log("DB result", result);
And more importantly, logging patterns that seem harmless in isolation:
- Logging inside hot request paths — code that executes on every incoming request
- Logging full nested objects (
req,res, database result sets) - Logging inside loops — once per iteration
- Debug logs committed and running in production builds
At low traffic, none of this is noticeable.
At production load, the behavior changes completely.
What console.log Actually Does in Node.js
This is the part most developers have never had reason to look at closely.
console.log is not a fire-and-forget print statement. It is a synchronous write to a stream.
The call chain:
console.log(data)
│
v
process.stdout.write(serialized)
│
v
Node.js Writable Stream
│
v
OS Kernel Buffer
│
v
Disk / Terminal / Docker log collector
process.stdout is a Node.js Writable Stream. That means it has an internal buffer, it has a high-water mark, and it is subject to the same backpressure mechanics as every other stream in Node.js.
The critical difference between a terminal environment and a production environment:
| Environment | stdout behavior |
|---|---|
| Terminal (TTY) | Synchronous — write blocks until done |
| Pipe / Docker / CI | Asynchronous — buffered, subject to backpressure |
In production, your Node.js process is almost certainly writing to a pipe — a Docker container, a log aggregator, a file descriptor handed off by the orchestration layer. That means every console.log is interacting with an asynchronous stream, whether you think about it or not.
Where It Breaks: Stream Backpressure
Node.js streams implement backpressure as a flow control mechanism. When the consumer of a stream (the OS, the log collector, the file system) cannot keep up with the producer (your application), the stream signals this:
Application floods stdout with logs
│
v
stdout internal buffer fills up
│
v
stream.write() returns false ← "slow down"
│
v
Application keeps writing anyway ← console.log does not check this
│
v
Backlog accumulates in Node.js memory
│
v
Event loop slows under I/O pressure
│
v
Memory climbs. Latency climbs. System degrades.
The key problem: console.log does not respect backpressure. It calls process.stdout.write() and does not wait for a drain event before writing again. Under normal load, this is invisible. Under high load with many logs per request, the backlog builds continuously.
Why It Looked Like a Memory Leak
Three mechanisms combined to produce the memory leak appearance:
1. Buffered writes accumulating
Node.js holds unwritten stream data in memory until the OS can accept it. When log volume exceeds the throughput of the downstream consumer, that buffer grows without bound.
2. Object serialization overhead
Logging large objects is not free:
console.log("DB result", result); // result may be hundreds of nested objects
Each call triggers JSON serialization, deep object traversal, and temporary memory allocation. Under high throughput, these allocations happen faster than the garbage collector can reclaim them, producing a characteristic sawtooth memory pattern — the one commonly misread as a leak.
3. Event loop competition
Heavy stdout I/O competes with the event loop for CPU time. When logging is frequent enough, it begins to crowd out:
- Incoming request callbacks
- Garbage collection cycles
- Promise resolution
- Timer callbacks
The result is a system that looks overloaded because it is overloaded — just not by the business logic anyone was looking at.
Why Removing Logs Appeared to "Fix" It
When the team stripped out console logs, the symptoms disappeared:
- Memory usage flattened
- Latency dropped back to baseline
- The server ran stably for weeks
This created a reasonable but incomplete conclusion:
"console.log caused a memory leak."
The more precise understanding:
"Uncontrolled synchronous logging at production request volume created I/O backpressure and memory pressure that mimicked a memory leak and degraded event loop responsiveness."
Removing the logs was not the fix. It was confirmation of the root cause. The actual fix required building a proper logging strategy.
The Proper Fix: Structured Logging with Winston
Replacing raw console.log calls with Winston addressed the problem at its root.
Why Winston changes the behavior
- Asynchronous transports — log writes are handled off the critical path
- Log level control — debug and verbose logs are suppressed in production at configuration, not by removing code
- Structured JSON output — consistent, parseable format suitable for log aggregators
- Transport separation — write to files, external services, or stdout with independent configuration
- Hot-path safety — logging a few fields instead of entire objects
Production-ready Winston setup
// src/infrastructure/logger/winston.logger.ts
import winston from "winston";
const { combine, timestamp, json, errors } = winston.format;
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: combine(
timestamp(),
errors({ stack: true }),
json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({
filename: "logs/error.log",
level: "error",
}),
new winston.transports.File({
filename: "logs/app.log",
}),
],
});
Usage in the service layer
// src/features/auth/application/auth.service.ts
import { logger } from "@/infrastructure/logger/winston.logger";
export async function signIn(userId: string) {
logger.info("User sign-in attempt", { userId });
// business logic...
logger.info("User sign-in successful", { userId });
}
Notice what is being logged: a specific field (userId), not the entire user object. That discipline is as important as the library choice.
Architectural Lessons Learned
1. Logging is a subsystem, not a utility
Treat your logging layer the same way you treat your database connection pool or your HTTP client — as infrastructure with real performance characteristics.
It is rate-sensitive. It is I/O-bound. It has failure modes. It needs configuration per environment.
It is not "just console statements".
2. Never log raw objects in hot paths
// ❌ This serializes the entire request object on every call
console.log("Request received", req);
// ✅ Log only the fields you actually need
logger.info("Request received", {
method: req.method,
path: req.path,
userId: req.user?.id,
});
The second pattern costs a fraction of the memory, produces more useful output, and is easier to query in a log aggregator.
3. Production and development logging are different problems
| Environment | Goal | Approach |
|---|---|---|
| Development | Maximum visibility | Verbose, pretty-printed, all levels |
| Production | Signal over noise | Structured JSON, info and above, external sink |
Use LOG_LEVEL environment variables to control this without changing code.
4. Understand what you are interacting with
When you call console.log, you are writing to a stream. That stream has a buffer. That buffer has limits. Under load, those limits matter.
This is not a Node.js quirk — it is how UNIX I/O works. Node.js just makes it easy to forget until it becomes a problem.
5. Log volume compounds quickly at scale
1 request → 5 log statements
10,000 req/s → 50,000 log writes/sec
Each of those writes involves serialization, buffering, and I/O. At the right combination of log volume and request throughput, logging alone can become the dominant performance cost in a system.
A Quick Checklist for Production Logging
Before shipping, ask:
- Are any
console.logcalls in hot request paths? - Are full objects (
req,res, query results) being logged anywhere? - Is the log level configurable per environment?
- Is debug-level logging suppressed in production?
- Are logs going to a structured sink (file, aggregator) rather than only stdout?
- Is there a log rotation or retention strategy?
Final Takeaway
What looked like a mysterious memory leak was a system-level pressure problem caused by uncontrolled logging interacting with Node.js stream internals under production load.
The lesson is not "don't log things". Logging is essential for observability, debugging, and understanding production behavior.
The lesson is that logging — like every other I/O operation in Node.js — has real costs that become visible at scale. Understanding those costs, and building a logging strategy that accounts for them, is part of engineering a production-grade system.
console.log is a development tool. Winston — or any structured, asynchronous logging library — is a production tool.
The difference matters more than most engineers expect, usually at the worst possible time.
Thanks for reading. I hope you come back.