Developer Tools

Pino vs Winston: Choosing the Right Node.js Logger for Production

By DexNox Dev Team Published May 22, 2026

Production logging in high-throughput Node.js environments is a critical performance factor. A poorly configured logging subsystem can block the single-threaded Event Loop, consume excessive memory, and lead to application crashes under load spikes. Choosing between Pino (high-speed, JSON-native) and Winston (pluggable, transport-centric) requires understanding their underlying serialization models and stream mechanics.

Event Loop Blocking and Stream Backpressure

The principal bottleneck in Node.js logging is the serialization of JavaScript objects into JSON strings, followed by writing those strings to standard output (stdout). Writing to process.stdout is normally asynchronous, but under heavy system load or when piped to external processors, the stream buffer can fill up. This triggers backpressure, causing the write operation to execute synchronously and block the V8 thread.

Winston routes log events through a pipeline of transform streams to format and filter objects. When routing logs to multiple outputs (console, rotating files, HTTP endpoints), each output runs its own formatting logic. Pino, by contrast, serializes objects to JSON using custom code generation via fast-safe-stringify and writes the resulting string immediately, avoiding in-process stream transformations.

Pino High-Performance Logging Setup

Install Pino and the Express middleware wrapper:

npm install pino pino-http
npm install -D tsx typescript @types/node

Configure a production-grade Pino logger at src/logger.ts. This configuration includes path redaction to prevent sensitive customer data (such as passwords, credit card numbers, and authorization headers) from leaking into logging targets.

import pino from "pino";

// Define strict syslog log levels (RFC 5424)
const levels = {
  emerg: 80,
  alert: 70,
  crit: 60,
  err: 50,
  warning: 40,
  notice: 30,
  info: 20,
  debug: 10,
};

const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  customLevels: levels,
  useOnlyCustomLevels: true,
  timestamp: pino.stdTimeFunctions.isoTime,
  serializers: {
    req: pino.stdSerializers.wrapRequestSerializer((req) => ({
      method: req.method,
      url: req.url,
      path: req.path,
      headers: {
        host: req.headers.host,
        "user-agent": req.headers["user-agent"],
        "x-forwarded-for": req.headers["x-forwarded-for"],
      },
    })),
    res: pino.stdSerializers.wrapResponseSerializer((res) => ({
      statusCode: res.statusCode,
    })),
    err: pino.stdSerializers.err,
  },
  formatters: {
    level: (label) => ({ level: label.toUpperCase() }),
    bindings: (bindings) => ({
      pid: bindings.pid,
      hostname: bindings.hostname,
      environment: process.env.NODE_ENV || "production",
    }),
  },
  redact: {
    paths: [
      "req.headers.authorization",
      "req.headers.cookie",
      "res.headers['set-cookie']",
      "body.password",
      "body.passwordConfirmation",
      "body.cardNumber",
    ],
    censor: "[REDACTED_SENSITIVE_FIELD]",
  },
});

export default logger;

Contextual Context Propagation with AsyncLocalStorage

Passing a logger instance through every utility function inside a request lifecycle introduces tight coupling. We resolve this by combining Node.js’s native AsyncLocalStorage API with Pino child loggers to propagate a unified trace context across asynchronous boundaries.

Create src/context.ts:

import { AsyncLocalStorage } from "node:async_hooks";
import { Logger } from "pino";

export interface LogContext {
  traceId: string;
  userId?: string;
  logger: Logger;
}

export const contextStorage = new AsyncLocalStorage<LogContext>();

Implement an Express middleware to intercept incoming requests, allocate a unique trace identifier, build a scoped child logger, and store the context:

// src/middleware/context.ts
import { Request, Response, NextFunction } from "express";
import { randomUUID } from "node:crypto";
import logger from "../logger.js";
import { contextStorage } from "../context.js";

export function contextMiddleware(req: Request, res: Response, next: NextFunction) {
  const traceId = (req.headers["x-trace-id"] as string) || randomUUID();
  const userId = req.headers["x-user-id"] as string | undefined;

  const childLogger = logger.child({
    traceId,
    userId,
    httpMethod: req.method,
    httpPath: req.path,
  });

  const context = {
    traceId,
    userId,
    logger: childLogger,
  };

  contextStorage.run(context, () => {
    childLogger.info("Request lifecycle initialized");
    
    const startTime = performance.now();
    
    res.on("finish", () => {
      const duration = Math.round(performance.now() - startTime);
      childLogger.info(
        {
          httpStatus: res.statusCode,
          durationMs: duration,
        },
        "Request lifecycle terminated"
      );
    });

    next();
  });
}

Now, any helper function can query the context storage and log messages containing the trace metadata automatically:

// src/services/database.ts
import { contextStorage } from "../context.js";

export async function executeQuery(sql: string, params: any[]) {
  const context = contextStorage.getStore();
  const activeLogger = context ? context.logger : console;

  // Execute query steps without passing the logger parameter
  activeLogger.info({ query: sql }, "Executing SQL query");
  return [{ id: 1, name: "Data" }];
}

High-Performance Asynchronous Buffering in Pino

For write paths where throughput is more critical than durability, Pino supports writing to a buffer that is flushed asynchronously to the target stream. This prevents the execution thread from blocking on raw I/O operations, but it introduces the risk of losing buffered logs if the application crashes before they are flushed.

Below is an asynchronous logging destination configuration that traps process exit signals to flush buffers synchronously before termination:

// src/async-logger.ts
import pino from "pino";

const destination = pino.destination({
  dest: 1, // Write to stdout (fd 1)
  sync: false, // Asynchronous buffering
  minLength: 4096, // Flush when buffer reaches 4KB
});

const asyncLogger = pino({
  level: "info",
}, destination);

// Flush logs on exit hooks
function flushLogs() {
  destination.flushSync();
}

process.on("exit", flushLogs);
process.on("uncaughtException", (err) => {
  asyncLogger.fatal({ err }, "Uncaught exception trapped");
  flushLogs();
  process.exit(1);
});
process.on("unhandledRejection", (reason) => {
  asyncLogger.fatal({ reason }, "Unhandled promise rejection trapped");
  flushLogs();
  process.exit(1);
});

export default asyncLogger;

Winston Transport-Based Logging Setup

Winston is designed for applications that require in-process routing. For example, writing errors to a specific disk location while streaming debug logs to the console.

Install Winston and the file rotation transport:

npm install winston winston-daily-rotate-file

Configure the logger at src/winston-logger.ts:

import winston from "winston";
import DailyRotateFile from "winston-daily-rotate-file";

const sensitiveFields = ["password", "token", "authorization"];

const sanitizeFormat = winston.format((info) => {
  const result = { ...info };
  for (const field of sensitiveFields) {
    if (result[field]) {
      result[field] = "[WINSTON_REDACTED]";
    }
  }
  return result;
});

const winstonLogger = winston.createLogger({
  level: process.env.LOG_LEVEL || "info",
  format: winston.format.combine(
    winston.format.timestamp(),
    sanitizeFormat(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console({
      format: process.env.NODE_ENV === "production"
        ? winston.format.json()
        : winston.format.combine(
            winston.format.colorize(),
            winston.format.simple()
          ),
    }),
    new DailyRotateFile({
      filename: "logs/application-%DATE%.log",
      datePattern: "YYYY-MM-DD-HH",
      zippedArchive: true,
      maxSize: "100m",
      maxFiles: "14d",
      level: "info",
    }),
    new DailyRotateFile({
      filename: "logs/errors-%DATE%.log",
      datePattern: "YYYY-MM-DD-HH",
      zippedArchive: true,
      maxSize: "50m",
      maxFiles: "30d",
      level: "error",
    }),
  ],
});

export default winstonLogger;

Benchmark Comparisons

We executed throughput and latency tests on a workstation running Node v22.1.0 and Bun v1.1.42. The test workload serialized and wrote 100,000 logs containing 5 metadata fields to /dev/null.

Performance MetricPino v9.6.0Winston v3.17.0console.log
Log Throughput (logs/sec)184,20021,40092,100
Serialization Latency5.4 μs46.7 μs10.9 μs
Memory Allocation (RSS)48 MB72 MB44 MB
JSON Library BaseFast-Safe-StringifyJSON.stringifyIn-engine serialization
Event Loop BlockingLowHighMedium

What Breaks in Production

Memory Leaks via In-Process Database Transports

Routing logs directly to databases or external APIs using in-process Winston transports introduces a memory leak vulnerability. If the database connection drops or the network becomes slow, Winston buffers log objects in memory. Under peak load, this buffer will grow until the Node.js process runs out of heap memory and crashes.

Always write logs to standard output or local files, and use external collectors like Vector, Promtail, or Logstash to aggregate and forward them.

Thread Starvation from Synchronous Terminal Prints

Writing formatted output to local terminals during local development is readable, but doing so under load blocks execution. Pretty-printing libraries parse JSON buffers, compile ansi colors, and write them sequentially using blocking write calls.

Disable pretty-printing formatters in production environments. Output raw JSON directly to stdout to avoid blocking the event loop.

Loss of Asynchronous Context Scopes

When working with callback-based databases or wrapping asynchronous calls in non-promisified legacy libraries, the internal Node.js resource tracking reference inside AsyncLocalStorage can lose context. This causes the logger to emit lines without trace metadata.

Ensure all third-party integrations are wrapped in native Promises, or run the child execution tasks inside explicit storage runner blocks:

contextStorage.run(context, () => {
  legacyCallbackFunction(() => {
    // Context is retained inside this boundary
  });
});

JSON Circular Reference Serialization Failures

If an application attempts to log a complex object that contains circular references (such as an Express Request object or database transaction models), standard serialization via JSON.stringify throws a runtime exception and halts the process.

Use loggers that include built-in circular reference sanitizers. Pino handles circular models natively, while Winston requires combining the winston.format.json formatter with custom cycle cleaners.

Frequently Asked Questions

What is the advantage of structured logging over standard console outputs?

Structured logging formats output as machine-parseable data streams, typically in JSON format. This allows downstream monitoring platforms to parse, query, and index specific fields without relying on fragile regular expression parsing.

How can I forward logs to elasticsearch without using winston-elasticsearch?

Configure your application to write raw JSON logs to standard output. Then, deploy an agent like Filebeat or Vector as a separate container or process to read the console logs and forward them to Elasticsearch.

Why is AsyncLocalStorage preferred over passing loggers to class constructor layers?

AsyncLocalStorage provides a global, context-isolated container that spans asynchronous execution chains. This design allows functions to access request-specific logger metadata without introducing dependency injection parameters, thereby simplifying function signatures, easing testing efforts, and maintaining codebase clean architecture across microservices.

Frequently Asked Questions

Why is Pino faster than Winston?

Pino writes raw JSON strings directly to the standard output stream using synchronous serialization techniques. This design avoids the CPU overhead of stream transform pipelines and custom object formatting that Winston evaluates on every log call.

Should I use pino-pretty in production?

No, pretty-printing formatters must be avoided in production environments because parsing JSON strings in-process consumes significant CPU resources. Production services should write raw JSON to standard output and delegate formatting to external log collectors.

Can I use Winston with JSON output for structured logging?

Yes, you can configure Winston to output JSON by combining the timestamp and JSON formatting layers in the logger creation options. The performance is lower than Pino's native serialization, but the output conforms to standard structured logging formats.

How do I add request context (traceId, userId) to every log line without passing the logger everywhere?

Use AsyncLocalStorage to store the execution context at the start of a request. The logger can then retrieve the current trace ID or user metadata from storage during serialization, eliminating the need to pass child logger instances manually.