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.logbecame 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()returnsfalse- 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.