Node.js Logging: Winston and Pino

Production logging isn't optional—it's your primary debugging tool when things go wrong at 3 AM. Yet many Node.js applications still rely on `console.log()`, losing critical context, structured data,...

Key Insights

  • Winston offers maximum flexibility with multiple transports and complex formatting, making it ideal for applications requiring diverse logging destinations or legacy system integration
  • Pino delivers 5-10x better performance through asynchronous logging and minimal overhead, crucial for high-throughput microservices and low-latency applications
  • Your choice should prioritize performance for greenfield microservices (Pino) or flexibility for enterprise applications with complex logging requirements (Winston)

Introduction to Production Logging

Production logging isn’t optional—it’s your primary debugging tool when things go wrong at 3 AM. Yet many Node.js applications still rely on console.log(), losing critical context, structured data, and performance optimization opportunities.

Here’s the problem with basic console logging:

// Unstructured logging - difficult to parse and query
console.log('User login attempt');
console.log('Email:', email);
console.error('Login failed:', error.message);

Compare this to structured logging:

// Structured logging - machine-readable, queryable
logger.info({
  event: 'user_login_attempt',
  email: email,
  success: false,
  error: error.message,
  duration_ms: 145
});

Structured logs are JSON objects that log aggregation tools (ELK, Datadog, CloudWatch) can parse, index, and query efficiently. They include metadata like timestamps, severity levels, and correlation IDs automatically.

The two dominant Node.js logging libraries—Winston and Pino—take fundamentally different approaches to this problem. Winston prioritizes flexibility and features, while Pino obsesses over performance. Understanding these tradeoffs determines which logger fits your application architecture.

Winston: Feature-Rich and Flexible

Winston has been the Node.js logging standard for years, offering a transport-based architecture where logs can simultaneously flow to multiple destinations with different formats and filtering rules.

Here’s a production-ready Winston configuration:

const winston = require('winston');

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: 'user-service' },
  transports: [
    // Write all logs to console
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      )
    }),
    // Write all errors to error.log
    new winston.transports.File({ 
      filename: 'error.log', 
      level: 'error' 
    }),
    // Write all logs to combined.log
    new winston.transports.File({ 
      filename: 'combined.log' 
    })
  ]
});

logger.info('Service started', { port: 3000 });
logger.error('Database connection failed', { 
  error: new Error('Connection timeout'),
  retries: 3 
});

Winston’s transport system shines when you need different log destinations for different environments or severity levels. You can send errors to Sentry, warnings to Slack, and everything to CloudWatch—all with different formatting rules.

Custom formatting gives you complete control over log structure:

const customFormat = winston.format.printf(({ level, message, timestamp, ...metadata }) => {
  let msg = `${timestamp} [${level}]: ${message}`;
  if (Object.keys(metadata).length > 0) {
    msg += ` ${JSON.stringify(metadata)}`;
  }
  return msg;
});

const logger = winston.createLogger({
  format: winston.format.combine(
    winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    winston.format.errors({ stack: true }),
    customFormat
  ),
  transports: [new winston.transports.Console()]
});

Winston excels in enterprise environments with complex logging requirements, legacy system integration, or teams that need extensive customization without writing custom code.

Pino: Performance-First Approach

Pino takes a radically different approach: minimize overhead by doing as little work as possible in the main thread. It achieves 5-10x better performance than Winston by writing JSON to stdout asynchronously and delegating formatting to separate processes.

Basic Pino setup is deliberately minimal:

const pino = require('pino');

const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => {
      return { level: label };
    }
  },
  timestamp: pino.stdTimeFunctions.isoTime
});

logger.info({ port: 3000 }, 'Service started');
logger.error({ 
  err: new Error('Connection timeout'),
  retries: 3 
}, 'Database connection failed');

Notice the different API: Pino puts structured data first, message second. This encourages treating logs as data, not prose.

Child loggers are Pino’s killer feature for request tracking:

const pino = require('pino');
const logger = pino();

// Create child logger with request context
function handleRequest(req, res) {
  const requestLogger = logger.child({ 
    requestId: req.id,
    userId: req.user?.id 
  });
  
  requestLogger.info('Request started');
  
  // All logs from this request include requestId and userId
  processRequest(requestLogger);
  
  requestLogger.info({ duration: 145 }, 'Request completed');
}

Custom serializers handle sensitive data redaction and complex object formatting:

const logger = pino({
  serializers: {
    user: (user) => {
      return {
        id: user.id,
        email: user.email.replace(/(.{2}).*(@.*)/, '$1***$2')
      };
    },
    req: (req) => ({
      method: req.method,
      url: req.url,
      // Exclude headers that might contain tokens
      headers: { ...req.headers, authorization: undefined }
    })
  }
});

logger.info({ user: { id: 123, email: 'user@example.com', password: 'secret' } });
// Output: {"user":{"id":123,"email":"us***@example.com"}}

For pretty-printing during development, pipe Pino output through pino-pretty:

node app.js | pino-pretty

Feature Comparison

Let’s implement identical logging scenarios in both libraries:

// Winston
const winston = require('winston');
const winstonLogger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  defaultMeta: { service: 'api' },
  transports: [new winston.transports.Console()]
});

winstonLogger.info('User action', { 
  userId: 123, 
  action: 'purchase',
  amount: 99.99 
});

// Pino
const pino = require('pino');
const pinoLogger = pino({ base: { service: 'api' } });

pinoLogger.info({ 
  userId: 123, 
  action: 'purchase',
  amount: 99.99 
}, 'User action');

Key differences:

Configuration Complexity: Winston requires more setup but offers more options. Pino’s defaults are production-ready.

Transport Options: Winston has 40+ community transports (databases, cloud services, message queues). Pino uses separate transport processes via pino-transport or external tools.

Formatting: Winston provides extensive built-in formatters. Pino outputs raw JSON and delegates formatting to CLI tools like pino-pretty or log aggregators.

Ecosystem: Winston has broader plugin ecosystem. Pino has faster-growing adoption in modern microservices.

Performance Benchmarks

Performance matters when you’re logging hundreds of requests per second. Here’s a simple benchmark:

const Benchmark = require('benchmark');
const winston = require('winston');
const pino = require('pino');

const winstonLogger = winston.createLogger({
  transports: [new winston.transports.Console()]
});

const pinoLogger = pino(pino.destination('/dev/null'));

const suite = new Benchmark.Suite();

suite
  .add('Winston', () => {
    winstonLogger.info('Test message', { userId: 123, action: 'test' });
  })
  .add('Pino', () => {
    pinoLogger.info({ userId: 123, action: 'test' }, 'Test message');
  })
  .on('cycle', (event) => {
    console.log(String(event.target));
  })
  .on('complete', function() {
    console.log('Fastest is ' + this.filter('fastest').map('name'));
  })
  .run();

In real-world tests, Pino consistently outperforms Winston by 5-10x for basic logging operations. Memory usage is also significantly lower—critical for containerized applications with memory limits.

However, these differences matter most in high-throughput scenarios (>1000 req/s). For typical CRUD applications, Winston’s overhead is negligible.

Integration Patterns

Express middleware for request logging with Winston:

const winston = require('winston');
const expressWinston = require('express-winston');

app.use(expressWinston.logger({
  transports: [new winston.transports.Console()],
  format: winston.format.json(),
  meta: true,
  msg: "HTTP {{req.method}} {{req.url}}",
  expressFormat: true,
  colorize: false
}));

Pino with Express using pino-http:

const express = require('express');
const pino = require('pino');
const pinoHttp = require('pino-http');

const logger = pino();
const app = express();

app.use(pinoHttp({ logger }));

app.get('/api/users/:id', (req, res) => {
  req.log.info({ userId: req.params.id }, 'Fetching user');
  res.json({ id: req.params.id });
});

Request correlation ID tracking with Pino:

const { v4: uuidv4 } = require('uuid');

app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || uuidv4();
  req.log = logger.child({ requestId: req.id });
  next();
});

Choosing the Right Logger

Choose Pino when:

  • Building high-throughput microservices where every millisecond counts
  • Working with modern cloud-native infrastructure (Kubernetes, serverless)
  • Your team values performance and simplicity over extensive features
  • You’re starting a greenfield project with modern tooling

Choose Winston when:

  • You need multiple simultaneous log destinations with different formats
  • Integrating with legacy systems or enterprise logging infrastructure
  • Your team is already familiar with Winston and migration costs outweigh benefits
  • You require complex filtering and formatting logic in the application layer

Configuration templates for common scenarios:

// Microservices (Pino)
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  formatters: {
    level: (label) => ({ level: label })
  }
});

// Monolith with multiple outputs (Winston)
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.Console(),
    new winston.transports.File({ filename: 'app.log' }),
    new WinstonCloudWatch({ /* config */ })
  ]
});

// Serverless (Pino with minimal overhead)
const logger = pino({
  level: 'info',
  base: null, // Remove pid, hostname for serverless
  timestamp: false // CloudWatch adds timestamps
});

Both libraries are production-proven. Winston gives you maximum flexibility at the cost of performance. Pino gives you maximum performance with a simpler, more opinionated API. Choose based on your constraints, not trends.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.