Production-grade logging is one of the most overlooked aspects of Node.js application development. While console.log works for debugging locally, it falls apart in distributed environments where logs must be searchable, structured, and actionable. This article covers the essential patterns for building a logging strategy that scales.
Why Structured Logging
Traditional unstructured logging outputs plain text that is difficult to parse programmatically. Consider console.log("User logged in:", userId). Grepping this across hundreds of service instances is slow and error-prone. Structured logging outputs each log event as a JSON object, making it machine-readable and queryable by log aggregation systems.
// Unstructured - avoid in production
console.log("Payment processed:", paymentId, "for user:", userId);
// Structured - preferred
logger.info({ paymentId, userId, amount, currency }, "Payment processed");
The JSON format enables field-based filtering, alerting, and dashboarding in tools like Elasticsearch, Loki, and Datadog without custom parsing logic.
Choosing a Logger: Pino vs. Winston
Two libraries dominate the Node.js logging landscape. The right choice depends on your performance requirements and ecosystem needs.
| Feature | Pino | Winston |
|---|---|---|
| Speed | ~0.5 µs per log line | ~3–5 µs per log line |
| Transports | Fewer built-in, extensible via pino-multi-stream | Rich ecosystem (file, HTTP, Syslog, custom) |
| Child loggers | pino.child({ context }) | winston.createChild() |
| Redaction | Built-in via redact option | Requires fast-redact or manual approach |
| Dev experience | pino-pretty for human-readable output | Built-in formatting options |
Pino is the best choice for high-throughput services where every microsecond matters. Winston excels when you need complex transport routing or have existing infrastructure tied to its plugin model.
// Pino setup
const pino = require("pino");
const logger = pino({
level: process.env.LOG_LEVEL || "info",
redact: ["password", "authorization"],
transport: process.env.NODE_ENV !== "production"
? { target: "pino-pretty" }
: undefined,
});
Log Levels and When to Use Them
Adhering to RFC 5424 log levels ensures consistency across services. Each level signals the severity and actionability of an event.
| Level | Value | When to Use |
|---|---|---|
fatal | 60 | Application crash is imminent |
error | 50 | A request failed, but the process continues |
warn | 40 | Unexpected but non-critical situation |
info | 30 | Normal operational milestones |
debug | 20 | Detailed diagnostic information |
trace | 10 | Very detailed execution flow |
Control the log level dynamically via an environment variable so you can increase verbosity in production without redeploying:
const LOG_LEVEL_MAP = { fatal: 60, error: 50, warn: 40, info: 30, debug: 20, trace: 10 };
const currentLevel = LOG_LEVEL_MAP[process.env.LOG_LEVEL] || LOG_LEVEL_MAP.info;
Request Correlation IDs
In a microservices architecture, a single user request may traverse multiple services. Logging each service in isolation makes debugging impossible. The solution is to assign a unique correlation ID to every incoming request and propagate it across service boundaries.
const { AsyncLocalStorage } = require("async_hooks");
const asyncLocalStorage = new AsyncLocalStorage();
app.use((req, res, next) => {
const correlationId = req.headers["x-correlation-id"] || crypto.randomUUID();
res.setHeader("x-correlation-id", correlationId);
req.log = logger.child({ correlationId });
asyncLocalStorage.run(new Map([["correlationId", correlationId]]), () => next());
});
Use AsyncLocalStorage to access the correlation ID from any async context without passing it through every function signature. Forward the header to downstream services via HTTP clients so end-to-end tracing works across your entire architecture.
Transport Integration
Transports define where log output goes. The console transport is the default in containerized environments, but many production setups require multiple destinations.
// Example: logging to both console and a remote endpoint
const pino = require("pino");
const logger = pino({
level: "info",
}, pino.multistream([
{ stream: process.stdout },
{ stream: pino.transport({ target: "pino-http", options: { uri: "https://logs.example.com" } }) },
]));
Avoid synchronous transports in production — they block the event loop and negate the performance benefits of asynchronous I/O.
Centralized Logging with ELK and Loki
Sending logs to a centralized system turns raw text into a searchable observability platform.
ELK Stack: Ship JSON logs via Filebeat or Logstash to Elasticsearch, then visualize in Kibana. A typical Docker Compose setup includes Elasticsearch, Logstash (with a JSON input plugin), and Kibana.
Loki + Grafana: Loki indexes log streams by labels rather than full-text, making it more cost-effective for high-volume logging in Kubernetes environments. Pair it with Grafana for dashboarding and alerting.
# docker-compose.yml snippet for local ELK
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.x
logstash:
image: docker.elastic.co/logstash/logstash:8.x
kibana:
image: docker.elastic.co/kibana/kibana:8.x
Error Logging and Redaction
Always log errors with their full stack traces. Pino automatically serializes Error objects when passed under the err key:
try {
await processPayment(order);
} catch (err) {
logger.error({ err, orderId: order.id }, "Payment processing failed");
}
Never log sensitive data. Pino’s built-in redaction removes specified paths from the output:
const logger = pino({
redact: ["password", "authorization", "req.headers.cookie", "creditCard"],
});
Performance Considerations
Logging is I/O, and excessive I/O degrades application throughput. Key benchmarks show Pino logging in roughly 0.5 µs per line compared to Winston’s 3–5 µs. For high-traffic endpoints, consider sampling — log only a percentage of events — and always use asynchronous transports. At the warn level, lower-level calls like logger.debug() should be nearly free; Pino achieves this through level short-circuiting.
Conclusion
Structured JSON logging, careful library selection, correlation IDs, and centralized aggregation are the foundation of observable Node.js applications. Start by choosing the right logger for your workload, implement correlation IDs from day one, and route logs to a system that supports field-based querying. With these patterns in place, debugging production issues becomes a matter of running a search query rather than combing through flat files.
