paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

May 10, 2026

Node.js Logging in Production: Morgan vs. Winston vs. Pino - and When to Use Each

A practical guide to choosing, combining, and scaling your logging strategy in Node.js applications

Introduction

Logging is one of those cross-cutting concerns that engineers routinely underestimate at the start of a project and desperately wish they had taken more seriously in production. In Node.js, the ecosystem offers a deceptively wide range of logging tools - from the dead-simple console.log to highly configurable libraries with async transports, structured JSON output, and sub-millisecond overhead. The sheer variety leads to a common question: should I use Morgan, Winston, or Pino - and what exactly is the difference?

The honest answer is that these tools do not entirely overlap. Morgan is an HTTP request logger middleware; Winston is a general-purpose, transport-based logger; and Pino is a performance-first structured logger built around JSON. Understanding what problem each one was designed to solve - and how they interact - is more important than memorizing a feature matrix. This article walks through each library in depth, benchmarks them in context, examines the realistic alternatives, and gives you a decision framework you can apply immediately to your own system.

Whether you are greenfielding a microservice, retrofitting observability into a legacy monolith, or tuning a high-throughput API for latency, there is a logging strategy appropriate to your situation. The goal here is not to declare a winner but to give you the mental model and practical examples to make an informed choice.

The Problem with "Just Use console.log"

Before evaluating libraries, it is worth articulating why raw console.log statements fail in serious production environments. On the surface, console.log works. It is synchronous, universally available, and requires zero configuration. For a personal script or a weekend prototype, that is entirely sufficient. The problems emerge at scale and in operationally complex environments.

First, console.log produces unstructured text. When your application is emitting hundreds or thousands of log lines per second across a horizontally scaled fleet, plain text is nearly impossible to query efficiently in a log aggregation platform like Elasticsearch, Datadog, Loki, or Splunk. You cannot filter by severity, trace a request ID across services, or alert on a structured field without expensive regex parsing - which is both fragile and costly.

Second, console.log and console.error are synchronous writes to stdout and stderr respectively. In I/O-heavy Node.js applications, synchronous writes block the event loop. While a single line is fast, a burst of logging under traffic spikes can introduce measurable latency into your request pipeline. Production loggers address this with asynchronous buffering and stream-based writes. Third, there is no concept of log levels built into the console API. Controlling verbosity between development and production, or suppressing debug logs in performance-sensitive paths, requires ad hoc environment checks scattered throughout your codebase. The libraries covered in this article solve all three of these problems in different ways.

Understanding the Landscape: Three Different Tools for Three Different Jobs

A common source of confusion is treating Morgan, Winston, and Pino as direct competitors when they operate at different layers of the logging stack. Getting this distinction right is the first step toward a coherent logging architecture.

Morgan is an HTTP request logging middleware for Express and Connect. Its entire job is to capture inbound HTTP requests - method, URL, status code, response time, content length - and emit a log line per request. It does not know about application-level events, it does not support custom log levels, and it does not write to files or remote transports on its own. Morgan is a formatting and emission layer that sits in your Express middleware chain and delegates actual output to a write stream. You will almost always pair Morgan with a lower-level transport or redirect its output into Winston or Pino.

Winston is a general-purpose logging library. It introduces the concept of transports (destinations - console, file, HTTP, stream, external services) and supports multiple simultaneous transports with per-transport log level filtering. Winston is the Swiss Army knife of the Node.js logging world: highly configurable, widely supported in the ecosystem, and backed by a large set of community transport plugins. It prioritizes flexibility over raw performance.

Pino is a structured, high-performance JSON logger. Its design philosophy is to minimize the overhead imposed on the application thread by deferring as much work as possible - formatting, serialization, transport - to a separate worker process. Pino's API is intentionally minimal, and it achieves throughput numbers that measurably outperform Winston in benchmark scenarios with high log volume. It is the preferred choice in latency-sensitive services and is the default logger in the Fastify framework.

Deep Dive: Morgan

What Morgan Actually Does

Morgan intercepts every HTTP request processed by Express and emits a single log line per request-response cycle. It is configured with a format string - either a named preset like combined, common, dev, or short, or a custom token string - and a write stream. The combined format mirrors the Apache Combined Log Format and includes the remote address, timestamp, method, URL, status, response size, referrer, and user agent. The dev format emits a concise colored line suitable for local development.

The library's power lies in its simplicity and its stream-based output. By default, Morgan writes to process.stdout, but you can redirect its output to any writable stream - including a Winston logger's write stream. This is how most production applications combine the two: Morgan handles HTTP access log formatting, and Winston handles the actual transport.

// src/middleware/httpLogger.ts
import morgan from 'morgan';
import winston from 'winston';
import { Request, Response } from 'express';

const logger = winston.createLogger({
  level: 'http',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'logs/access.log' }),
  ],
});

// Create a write stream that delegates to Winston
const stream = {
  write: (message: string) => {
    logger.http(message.trim());
  },
};

// Skip logging in test environments
const skip = (_req: Request, _res: Response): boolean => {
  return process.env.NODE_ENV === 'test';
};

export const httpLogger = morgan('combined', { stream, skip });

Custom Tokens and Extending Morgan

Morgan's token system allows you to inject application-specific fields into the access log format. This is useful when you want to include a request ID, authenticated user ID, or tenant identifier in every HTTP log line without building a fully custom middleware.

import morgan from 'morgan';
import { Request, Response } from 'express';

// Register a custom token that reads a correlation ID from headers
morgan.token('correlation-id', (req: Request) => {
  return req.headers['x-correlation-id'] as string || 'none';
});

morgan.token('user-id', (req: Request) => {
  // Assumes req.user is populated by an auth middleware upstream
  return (req as any).user?.id || 'anonymous';
});

export const httpLogger = morgan(
  ':method :url :status :res[content-length] - :response-time ms | corr=:correlation-id user=:user-id',
  { stream: process.stdout }
);

While Morgan's token system is flexible enough for most access logging needs, it is deliberately not a general logging API. The moment you need to log application events, errors with stack traces, or business domain events, you need a complementary library like Winston or Pino.

Deep Dive: Winston

Architecture and Core Concepts

Winston organizes logging around three core abstractions: loggers, transports, and formats. A logger is the entry point your application code interacts with. A transport is a destination for log output - the library ships with Console, File, Http, and Stream transports, and the community has produced dozens of additional packages for services like Datadog, Loggly, Sentry, and Elasticsearch. A format is a composable transformation pipeline applied to log entries before they are written to a transport.

The default log levels in Winston follow npm's severity ordering: error, warn, info, http, verbose, debug, silly. You can define entirely custom level sets - including custom colors for console output - which makes Winston adaptable to domain-specific vocabularies. Each transport can be configured with its own minimum level, so you might log info and above to the console while writing error and above to a separate file or remote alerting service.

// src/lib/logger.ts
import winston, { format } from 'winston';
import path from 'path';

const { combine, timestamp, errors, splat, json, colorize, printf } = format;

// Human-readable format for local development
const devFormat = combine(
  colorize({ all: true }),
  timestamp({ format: 'HH:mm:ss' }),
  errors({ stack: true }),
  printf(({ level, message, timestamp, stack, ...meta }) => {
    const metaStr = Object.keys(meta).length ? JSON.stringify(meta, null, 2) : '';
    return `${timestamp} [${level}]: ${stack || message} ${metaStr}`;
  })
);

// JSON format for production ingestion
const prodFormat = combine(
  timestamp(),
  errors({ stack: true }),
  splat(),
  json()
);

const isProduction = process.env.NODE_ENV === 'production';

export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
  format: isProduction ? prodFormat : devFormat,
  defaultMeta: {
    service: process.env.SERVICE_NAME || 'api',
    version: process.env.APP_VERSION || 'unknown',
  },
  transports: [
    new winston.transports.Console(),
    ...(isProduction
      ? [
          new winston.transports.File({
            filename: path.join('logs', 'error.log'),
            level: 'error',
            maxsize: 10 * 1024 * 1024, // 10MB
            maxFiles: 5,
          }),
          new winston.transports.File({
            filename: path.join('logs', 'combined.log'),
            maxsize: 20 * 1024 * 1024, // 20MB
            maxFiles: 10,
          }),
        ]
      : []),
  ],
  exceptionHandlers: [
    new winston.transports.File({ filename: path.join('logs', 'exceptions.log') }),
  ],
  rejectionHandlers: [
    new winston.transports.File({ filename: path.join('logs', 'rejections.log') }),
  ],
});

Child Loggers and Context Propagation

One of Winston's most practically valuable features is the child logger pattern. A child logger inherits the parent's transports and configuration but carries additional default metadata. In a request handler, you create a child logger bound to the request's correlation ID, and every log emitted through that child automatically includes the ID - eliminating manual context threading.

// src/middleware/requestContext.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../lib/logger';

// Extend Express Request type to carry a contextual logger
declare global {
  namespace Express {
    interface Request {
      log: typeof logger;
      correlationId: string;
    }
  }
}

export const requestContextMiddleware = (
  req: Request,
  _res: Response,
  next: NextFunction
): void => {
  req.correlationId =
    (req.headers['x-correlation-id'] as string) || uuidv4();

  // Child logger automatically includes correlationId in every log line
  req.log = logger.child({
    correlationId: req.correlationId,
    method: req.method,
    path: req.path,
  });

  next();
};

// Usage in a route handler
// router.get('/orders/:id', async (req, res) => {
//   req.log.info('Fetching order', { orderId: req.params.id });
//   const order = await orderService.getById(req.params.id);
//   req.log.debug('Order fetched successfully', { orderId: order.id });
//   res.json(order);
// });

This pattern is idiomatic in larger Express applications and is one of the reasons Winston remains the dominant logger in the Node.js ecosystem despite newer, faster alternatives. The trade-off is overhead: Winston's flexible format pipeline adds latency per log call compared to more constrained libraries.

Deep Dive: Pino

Design Philosophy and Performance

Pino was built with a single overriding constraint: logging must not materially degrade application latency. The library achieves this through two mechanisms. First, it serializes log entries to JSON using a highly optimized serializer, minimizing object traversal and string allocation. Second, and more importantly, when pino.transport is used, the actual I/O work - writing to a file, forwarding to a remote service - is delegated to a separate worker thread via Node.js's worker_threads module. The application thread emits a minimal JSON string and continues; the worker handles the rest.

The practical result is that Pino's throughput in benchmarks is substantially higher than Winston's when log volume is significant. The Pino repository publishes benchmarks comparing itself to Winston and Bunyan on their own hardware; independent reproductions consistently show Pino logging millions of entries per second while Winston is an order of magnitude slower under the same conditions. In a typical CRUD API with moderate log volume this difference is imperceptible. In a high-frequency trading service, a stream processing engine, or a WebSocket gateway handling tens of thousands of concurrent connections, this gap matters.

// src/lib/logger.ts (Pino version)
import pino from 'pino';

const isProduction = process.env.NODE_ENV === 'production';

export const logger = pino({
  level: process.env.LOG_LEVEL || (isProduction ? 'info' : 'debug'),
  // In production: pure JSON. In development: use pino-pretty for readability.
  transport: isProduction
    ? undefined
    : {
        target: 'pino-pretty',
        options: {
          colorize: true,
          translateTime: 'SYS:HH:MM:ss',
          ignore: 'pid,hostname',
        },
      },
  base: {
    service: process.env.SERVICE_NAME || 'api',
    version: process.env.APP_VERSION || 'unknown',
    env: process.env.NODE_ENV || 'development',
  },
  serializers: {
    // Redact sensitive fields from request serialization
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
    err: pino.stdSerializers.err,
  },
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'body.password',
      'body.creditCard',
    ],
    censor: '[REDACTED]',
  },
});

Using Pino with Fastify and Express

Pino is the built-in logger for Fastify, which exposes it directly on every request object with automatic request ID injection. When using Express, the recommended approach is pino-http, a request middleware equivalent to Morgan that produces structured JSON access logs using Pino's serializers.

// src/app.ts - Pino with Express via pino-http
import express from 'express';
import pinoHttp from 'pino-http';
import { logger } from './lib/logger';

const app = express();

// pino-http automatically injects req.log with bound request context
app.use(
  pinoHttp({
    logger,
    // Assign custom request IDs (falls back to auto-increment)
    genReqId: (req) =>
      (req.headers['x-correlation-id'] as string) ||
      `req-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    // Customize what gets logged at the response level
    customSuccessMessage: (req, res) =>
      `${req.method} ${req.url} completed`,
    customErrorMessage: (_req, res, err) =>
      `Request failed: ${err.message}`,
    // Don't log health check endpoints
    autoLogging: {
      ignore: (req) => req.url === '/health',
    },
  })
);

app.get('/orders/:id', async (req, res) => {
  // req.log is a Pino child logger with correlationId already bound
  req.log.info({ orderId: req.params.id }, 'Fetching order');
  // ... handler logic
});

The pino-transport Async Pipeline

For production deployments that need to write to files, forward logs to Loki, or ship to Datadog, Pino's transport configuration accepts a target module that runs in a separate worker. This is where Pino's architecture pays dividends: the worker can be slow, can buffer, can batch - none of that affects the application thread.

// src/lib/logger.ts - multi-target transport in production
import pino from 'pino';

export const logger = pino({
  level: 'info',
  transport: {
    targets: [
      // Console output via pino-pretty (dev only - remove in prod for raw JSON)
      {
        target: 'pino-pretty',
        level: 'debug',
        options: { colorize: true },
      },
      // Write structured JSON to rotating files via pino/file
      {
        target: 'pino/file',
        level: 'info',
        options: {
          destination: './logs/app.log',
          mkdir: true,
        },
      },
      // Forward errors to a separate high-priority file
      {
        target: 'pino/file',
        level: 'error',
        options: { destination: './logs/errors.log' },
      },
    ],
  },
});

The Alternatives Worth Knowing

Bunyan

Bunyan was one of the first Node.js loggers to make structured JSON output a first-class concern. Created by Trent Mick, it introduced the concept of the "record" - a plain JSON object with a standardized set of fields (name, hostname, pid, level, msg, time) - and the companion CLI tool bunyan for pretty-printing log streams. Bunyan's design heavily influenced Pino's architecture. Today, Bunyan's development is largely inactive, and for new projects Pino serves the same purpose with significantly better performance and an active maintenance track. Bunyan remains relevant primarily in older codebases where migration cost is not justified.

log4js-node

log4js-node is a Node.js port of Apache's Log4j library, offering appenders (analogous to Winston's transports), categories (named loggers with hierarchical inheritance), and layouts. It will feel immediately familiar to Java and .NET engineers accustomed to Log4j or NLog conventions. The library supports file rolling, SMTP appenders, clustered logging, and TCP/UDP forwarding. For teams migrating Java microservices to Node.js or maintaining a multi-language ecosystem where log configuration is standardized on Log4j patterns, log4js-node reduces cognitive overhead. It is not the fastest or most idiomatic choice for a greenfield Node.js project, but it is a legitimate option in cross-language organizations.

tslog

tslog is a TypeScript-native logger that leans heavily into type safety and minimal configuration. It auto-captures the call site (file name, line number, function name) without requiring source maps by analyzing the Error stack trace at log time. This is genuinely useful during development but adds measurable overhead per log call - making tslog better suited for development tooling, CLI applications, and lower-throughput services than for high-volume production APIs. Its native TypeScript types and structured object support make it appealing in TypeScript-first codebases where developer ergonomics outweigh raw throughput.

Signale

Signale occupies a different niche entirely: it is a stylized, opinionated logger aimed at CLI tools and developer experience rather than production observability. It provides named scopes, interactive logging (spinners, progress bars), and a visually distinct output format. Signale is not appropriate for production server-side applications but is genuinely excellent for build tools, code generators, and command-line utilities where human-readable terminal output is the primary deliverable.

The OpenTelemetry Angle

No modern logging discussion is complete without addressing OpenTelemetry. OTel's logs signal, while newer than its metrics and tracing signals, provides a vendor-neutral, semantically standardized way to emit structured log records. The @opentelemetry/api-logs and @opentelemetry/sdk-logs packages allow you to emit logs through an OTel pipeline - decorating them with span context for trace-log correlation - and export them to any OTel-compatible backend. If your organization is already investing in OTel for distributed tracing, coupling your logs to the same pipeline gives you automatic correlation between traces and log lines, which is transformative for incident investigation. Winston and Pino both have community packages that bridge their output into OTel's log exporter, so this is increasingly a layering decision rather than an either/or.


Performance Benchmarks in Context

Raw benchmark numbers deserve skepticism. The figures below reflect well-known relative orderings documented in the Pino repository and reproduced by the community, but actual numbers vary with hardware, Node.js version, and log format complexity. The intent here is directional, not precise.

LibraryRelative ThroughputJSON OutputAsync I/OWorker Thread
console.logBaselineNoNo (sync)No
Winston 3.x~3-5* consoleYesPartialNo
Bunyan~5-8* consoleYesNoNo
Pino (sync)~10-20* consoleYesYesNo
Pino (async transport)~20-40* consoleYesYesYes

For the overwhelming majority of Node.js services - those handling a few thousand requests per minute with moderate logging verbosity - the difference between Winston and Pino is not observable in production latency profiles. The inflection point tends to be around services emitting tens of thousands of log lines per second under load, or services where P99 tail latency is a hard requirement. At that point, Pino's worker-thread model genuinely changes the architecture's behavior.

It is also worth noting that synchronous transports in Winston (such as the default Console transport) are the primary contributor to its overhead. Winston's File transport uses fs.createWriteStream, which is buffered, and community transports vary widely. Profiling your specific application under realistic load is far more reliable than extrapolating from synthetic benchmarks.

Trade-offs and Common Pitfalls

Synchronous Logging Under Load

The most consequential mistake engineers make is enabling synchronous log transports in high-throughput paths and not noticing the latency impact until production. Winston's Console transport is synchronous by default. If you are logging a debug-level trace on every iteration of a tight loop or every message in a WebSocket handler, a synchronous write per iteration will crater throughput. The mitigation is twofold: use Pino's async transport for high-volume paths, and be disciplined about log levels - debug logs in hot paths should be gated behind a level check or disabled in production.

Log Volume and Storage Cost

Structured JSON logs are verbose. A single request-response cycle with a reasonably detailed access log, a few application events, and an error can produce 2-5 KB of log data. At 1,000 requests per second, that is 2-5 MB/s of log throughput - 170-430 GB per day before any replication or indexing overhead in your log aggregation platform. Engineers frequently underestimate this until they receive an unexpected cloud storage or Datadog ingestion bill. Practical mitigations include log sampling (logging a statistical fraction of successful requests), per-path rate limiting (logging at most one line per second for noisy health check endpoints), and tiered retention (full fidelity logs retained for 3 days, aggregated summaries for 30 days).

Circular References and Object Serialization

Passing complex domain objects directly to logger calls is a common source of subtle bugs. Winston's JSON format and Pino's JSON serializer both handle most cases gracefully, but circular references will throw or produce incomplete output. The idiomatic pattern is to pass plain, purposefully constructed context objects to log calls rather than passing req, service instances, or ORM entities directly. Pino's serializers option provides a controlled, per-key serialization hook that is the right abstraction for this problem.

Secret and PII Leakage

Log lines are one of the most common vectors for accidental PII and secret exposure. Authorization headers, session cookies, passwords in request bodies, credit card numbers - all of these can end up in log files if logging middleware is not carefully configured. Both Winston (via custom formats) and Pino (via the redact option) provide built-in mechanisms for field redaction. This should be configured at library initialization, not left to individual developers to remember at each call site.

// Pino redaction - configured once, applies everywhere
const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      '*.password',
      '*.token',
      '*.secret',
      'body.creditCardNumber',
    ],
    censor: '[REDACTED]',
  },
});

The "Logger Singleton" Anti-pattern in Tests

Sharing a logger singleton across your entire test suite without mocking or suppressing output produces noisy CI output and can mask test-specific logging assertions. The recommended approach is to export a factory function alongside or instead of the singleton, allow logger configuration to be injected via environment variables or constructor arguments, and in test environments either set LOG_LEVEL=silent (Pino) / LOG_LEVEL=error (Winston) or swap the transport for an in-memory stream that can be inspected in assertions.

Best Practices for Production Logging

Always Emit Structured JSON in Production

Plain text logs are appropriate for human eyes on a terminal during development. In production, your logs are consumed by machines first - log aggregation platforms, alerting rules, dashboards - and by humans second, through query interfaces. JSON logs are indexable, queryable, and schema-evolvable without regex changes in your SIEM or observability platform. Both Winston and Pino produce JSON natively; do not configure them otherwise in production.

Standardize Log Fields Across Services

In a microservices architecture, consistent field names across services are what make cross-service log correlation tractable. At minimum, every service should include: timestamp (ISO 8601 UTC), level, service (or app), version, correlationId (or traceId), and message. If you are adopting OpenTelemetry, align on the OTel log semantic conventions for field names. Encode this standard in a shared internal package distributed to all services - do not rely on convention alone.

Use Child Loggers for Request Context

Creating a child logger at request ingress and threading it through the call stack (via a context argument, AsyncLocalStorage, or framework-native mechanisms) is the single highest-leverage logging practice. It eliminates the need to manually include request identifiers at each log call site and guarantees that every log line in a request's processing chain carries the same correlation context. Both Winston and Pino support child loggers with identical APIs: logger.child({ correlationId, userId, tenantId }).

// Using AsyncLocalStorage to avoid threading logger through every function call
import { AsyncLocalStorage } from 'async_hooks';
import { logger } from './lib/logger';

interface LogContext {
  log: typeof logger;
}

const asyncLocalStorage = new AsyncLocalStorage<LogContext>();

export const getLogger = () => {
  const store = asyncLocalStorage.getStore();
  return store?.log ?? logger;
};

export const runWithRequestContext = (
  correlationId: string,
  fn: () => Promise<void>
): Promise<void> => {
  const childLogger = logger.child({ correlationId });
  return asyncLocalStorage.run({ log: childLogger }, fn);
};

// In any nested function, getLogger() returns the request-scoped child automatically
// async function doSomethingDeep() {
//   getLogger().info('Deep function called'); // includes correlationId without threading
// }

Implement Log Sampling for High-Volume Paths

Not every log line carries equal diagnostic value. Health check endpoints, static asset requests, and cache hits can generate enormous log volume with minimal observability benefit. Implementing sampling - logging 1% of successful health checks, for example - dramatically reduces ingestion costs without meaningfully degrading incident response capability. Both Pino and Winston support conditional logging; pino-http provides a autoLogging.ignore function that can implement arbitrary sampling logic.

Treat Error Logs as First-Class Alerts

Every logger.error() call in production should correspond to something actionable. If your error logs are so frequent that your alerting becomes noisy and engineers start ignoring them, you have a monitoring debt problem that no logging library can solve. Establish a convention: error means someone should be paged or it should feed into an error tracking system like Sentry. warn means something unexpected happened but the system recovered. Promote informational diagnostic output to debug or verbose. This discipline - not library selection - is what determines whether your logging strategy delivers value under incident conditions.

Rotate and Retain Logs Thoughtfully

File-based logging requires rotation to prevent unbounded disk growth. Winston's File transport supports maxsize and maxFiles options for basic rotation. For more sophisticated rotation - date-based, compressed archives, S3 offload - the community package winston-daily-rotate-file is the standard choice. Pino delegates this to your infrastructure (logrotate, Loki's retention policies, CloudWatch retention rules) rather than implementing it in the application, which is the correct architectural boundary in containerized environments where logs typically flow to stdout and are captured by the container runtime.

Decision Framework: When and What to Choose

The following guidance is intentionally opinionated. It reflects common patterns across production Node.js deployments, not universal rules.

Use Morgan + Winston when:

Use Pino (with pino-http) when:

Use tslog when:

Use log4js-node when:

Use OpenTelemetry Logs when:

Avoid Bunyan for new projects. Its architecture is sound but maintenance has stalled. Pino is its spiritual successor with active development.

Key Takeaways

Five practical steps you can apply immediately to your Node.js logging strategy:

  1. Separate HTTP access logging from application logging. Use Morgan or pino-http for request/response logging and a standalone logger instance for application events. They serve different consumers and have different retention requirements.

  2. Enable JSON output in all non-development environments. Set format: winston.format.json() or remove the pino-pretty transport outside of local development. Plain text logs are a maintenance liability in any aggregation platform.

  3. Create a child logger at every request boundary. Bind correlationId, userId, and tenantId to a child logger at the start of each request. Use AsyncLocalStorage to make it available without manual threading. Every downstream log line inherits the context automatically.

  4. Configure field redaction before your first deployment. Both Winston and Pino support it natively. Identify your sensitive fields - authorization headers, cookies, passwords, tokens - and configure redaction at library initialization, not call site by call site.

  5. Right-size your log level discipline. Audit your logger.error() calls and ensure each one is either actionable by an on-call engineer or feeding into an error tracker. Demote informational noise to debug. This single discipline change has more impact on observability signal quality than any library choice.

80/20 Insight

If you had to distill the entire logging problem space to its most impactful 20%, it would be this: structured JSON output + request-scoped child loggers + field redaction. These three practices account for the vast majority of the value that logging provides in production - incident correlation, security posture, and queryability in your observability platform. Every other consideration - library performance, transport configuration, log rotation strategy - matters, but it matters far less than getting these three things right from the first deployment.

The library you choose is secondary to the discipline with which you use it. A well-configured Winston setup with child loggers and JSON output will outperform a carelessly configured Pino setup from an operational value perspective every time, even if the raw bytes-per-second numbers say otherwise.

Conclusion

Morgan, Winston, and Pino each occupy a distinct position in the Node.js logging ecosystem. Morgan is a focused, composable HTTP middleware - you will use it with other tools, not instead of them. Winston is the ecosystem's workhorse general-purpose logger, prized for flexibility and transport breadth. Pino is the performance specialist, making the right choice when throughput, latency, and worker-thread isolation matter. The alternatives - Bunyan, log4js-node, tslog, Signale - each serve specific contexts that are legitimate but narrower.

The most important engineering decision is not which library to use but what logging architecture to build. Structured JSON, request correlation, field redaction, and disciplined level semantics are the practices that determine whether your logging investment pays off during a 3 a.m. incident. The library is the implementation detail. Start with those practices, select the library that supports them with the least friction for your stack, and revisit the choice only if production evidence - not benchmarks - suggests a problem.

References

  1. Pino Documentation - https://getpino.io/
  2. Winston Documentation - https://github.com/winstonjs/winston
  3. Morgan Documentation - https://github.com/expressjs/morgan
  4. pino-http Documentation - https://github.com/pinojs/pino-http
  5. Pino Benchmarks - https://github.com/pinojs/pino/blob/master/docs/benchmarks.md
  6. Bunyan - https://github.com/trentm/node-bunyan
  7. log4js-node - https://log4js-node.github.io/log4js-node/
  8. tslog - https://tslog.js.org/
  9. OpenTelemetry Logs Specification - https://opentelemetry.io/docs/specs/otel/logs/
  10. OpenTelemetry Log Semantic Conventions - https://opentelemetry.io/docs/specs/semconv/general/logs/
  11. Node.js AsyncLocalStorage API - https://nodejs.org/api/async_context.html
  12. Node.js Worker Threads - https://nodejs.org/api/worker_threads.html
  13. Fastify Logger Documentation - https://fastify.dev/docs/latest/Reference/Logging/
  14. winston-daily-rotate-file - https://github.com/winstonjs/winston-daily-rotate-file
  15. The Twelve-Factor App - Logs - https://12factor.net/logs - Factor XI establishes the canonical case for treating log streams as event streams rather than file-based concerns.