# Node.js Logging Best Practices: Structured JSON with Pino and Winston

> Master Node.js logging best practices with Pino or Winston. Learn to implement structured JSON logging, transaction IDs, and efficient log routing for better debugging and observability.

- Repository: [Yoni Goldberg/nodebestpractices](https://github.com/goldbergyoni/nodebestpractices)
- Tags: best-practices
- Published: 2026-02-26

---

**Use a mature logger like Pino or Winston to output structured JSON to stdout, include unique transaction IDs for every request, and let your container orchestrator handle log routing to aggregation platforms.**

The `goldbergyoni/nodebestpractices` repository defines production-grade standards for Node.js applications. According to the source code analysis, robust logging requires three tightly-coupled concepts: smart logging with mature libraries, smart aggregation via stdout/stderr, and smart visualization through centralized dashboards.

## The Three Pillars of Production-Grade Logging

The [`sections/production/smartlogging.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/production/smartlogging.md) file outlines a three-step approach that separates log generation from routing and analysis.

### Smart Logging with Mature Libraries

**Smart logging** starts with selecting a production-ready logger instead of `console.log`. The [`sections/errorhandling/usematurelogger.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/errorhandling/usematurelogger.md) file explicitly recommends **Pino** for high-performance scenarios and **Winston** for flexibility. Both libraries support structured JSON output, configurable log levels (`debug`, `info`, `warn`, `error`), and plugin ecosystems.

Structured logs must include contextual metadata such as user IDs, request paths, and a **unique transaction ID** generated at the entry point of each request. This correlation identifier propagates through all downstream log statements, enabling distributed tracing without a full APM suite.

### Smart Aggregation via stdout/stderr

**Smart aggregation** adheres to the 12-Factor App methodology by writing logs exclusively to `stdout` and `stderr`. The [`sections/production/logrouting.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/production/logrouting.md) file emphasizes that application code must never contain file paths or network endpoints for log shipping. Instead, the execution environment—whether Docker, Kubernetes, or AWS Lambda—captures the stream and forwards it to aggregation platforms like Elastic Stack, Splunk, or CloudWatch.

This separation of concerns allows operators to change log destinations without modifying application code or redeploying services.

### Smart Visualization

**Smart visualization** leverages dashboards such as Kibana, Grafana, or Splunk to transform raw JSON logs into actionable metrics. Teams can calculate error rates, latency percentiles, and resource utilization trends by querying structured fields rather than parsing raw text.

## Implementing Pino for High-Performance Logging

Pino prioritizes speed and minimal overhead. The following implementation demonstrates structured JSON output, error serialization, and transaction ID propagation as recommended in [`sections/errorhandling/usematurelogger.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/errorhandling/usematurelogger.md).

```javascript
// logger.js – central logger configuration
const pino = require('pino');
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  timestamp: pino.stdTimeFunctions.isoTime,
  base: null,                 // remove default pid and hostname
  serializers: {
    err: pino.stdSerializers.err // proper error stack serialization
  }
});

module.exports = logger;

```

```javascript
// app.js – request handling with transaction IDs
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const logger = require('./logger');

const app = express();

// Middleware to generate and attach transaction ID
app.use((req, res, next) => {
  req.id = uuidv4();
  logger.info({ 
    txnId: req.id, 
    method: req.method, 
    url: req.originalUrl 
  }, 'incoming request');
  next();
});

app.get('/users/:id', async (req, res) => {
  try {
    // Business logic here
    logger.info({ 
      txnId: req.id, 
      userId: req.params.id 
    }, 'user fetched');
    res.json({ id: req.params.id });
  } catch (err) {
    logger.error({ 
      err, 
      txnId: req.id 
    }, 'failed to fetch user');
    res.status(500).send('internal error');
  }
});

app.listen(3000);

```

All log lines output pure JSON to `stdout`, include the `txnId` for correlation, and utilize Pino's standard serializers for error objects.

## Configuring Winston for Flexible Logging

Winston excels when you need complex transport configurations or custom formatting. The following setup adheres to the stdout-only principle while demonstrating JSON structuring as defined in [`sections/errorhandling/usematurelogger.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/errorhandling/usematurelogger.md).

```javascript
// winstonLogger.js
const { createLogger, format, transports } = require('winston');
const { combine, timestamp, json, errors } = format;

const logger = createLogger({
  level: 'info',
  format: combine(
    timestamp(),
    errors({ stack: true }),   // capture stack traces
    json()
  ),
  defaultMeta: { service: 'my-node-app' },
  transports: [
    new transports.Console()    // stdout only
  ]
});

module.exports = logger;

```

```javascript
// route.js
const logger = require('./winstonLogger');
const { v4: uuidv4 } = require('uuid');

app.post('/order', (req, res) => {
  const txnId = uuidv4();
  logger.info({ 
    txnId, 
    payload: req.body 
  }, 'create order request');

  // Process order...
  
  logger.info({ 
    txnId, 
    orderId: 123 
  }, 'order created');
  res.status(201).json({ orderId: 123 });
});

```

## Externalizing Log Routing with Container Orchestration

Following the guidance in [`sections/production/logrouting.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/production/logrouting.md), application code must never specify log file paths or network endpoints. Instead, configure the execution environment to capture `stdout` and `stderr`.

The following Docker daemon configuration demonstrates routing logs directly to Splunk without application changes:

```json5
// daemon.json (Docker daemon configuration)
{
  "log-driver": "splunk",
  "log-opts": {
    "splunk-token": "<YOUR_TOKEN>",
    "splunk-url": "https://splunk.example.com:8088",
    "splunk-index": "node-logs",
    "splunk-sourcetype": "json"
  }
}

```

When running on Kubernetes, the container runtime collects stdout streams and forwards them to your cluster's logging stack. This approach satisfies the 12-Factor App methodology and allows operators to change log destinations independently of development cycles.

## Key Files in the Repository

The `goldbergyoni/nodebestpractices` repository contains authoritative guidance in these specific locations:

- **[`sections/errorhandling/usematurelogger.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/errorhandling/usematurelogger.md)** – Explains why to choose a mature logger (Pino/Winston) and provides implementation snippets.
- **[`sections/production/smartlogging.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/production/smartlogging.md)** – Describes the three-step "smart logging" approach covering structured logs, aggregation, and visualization.
- **[`sections/production/logrouting.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/production/logrouting.md)** – Argues for keeping log routing out of application code and using stdout/stderr with container orchestration.

## Summary

- **Choose mature loggers** like Pino or Winston instead of `console.log` to gain structured JSON output, log levels, and performance optimizations.
- **Include transaction IDs** in every log line to enable distributed tracing and correlation across microservices.
- **Write exclusively to stdout/stderr** and externalize routing to container orchestrators, following the 12-Factor App principles documented in [`sections/production/logrouting.md`](https://github.com/goldbergyoni/nodebestpractices/blob/main/sections/production/logrouting.md).
- **Leverage visualization tools** like Kibana or Grafana to transform structured logs into actionable metrics and alerts.

## Frequently Asked Questions

### What is the difference between Pino and Winston for Node.js logging?

**Pino** prioritizes performance and minimal overhead, making it ideal for high-throughput services. It uses a child logger pattern for context and enforces JSON output by default. **Winston** offers greater flexibility with custom transports, multiple output destinations, and complex formatting pipelines, which suits applications requiring sophisticated log routing logic within the process.

### Why should I avoid using console.log in production Node.js applications?

`console.log` writes to stdout synchronously without log levels, structured formatting, or error serialization. It lacks the ability to redact sensitive fields or rotate streams, and it blocks the event loop under high load. Mature loggers like Pino and Winston provide asynchronous, non-blocking writes, configurable levels, and JSON structuring that downstream aggregation systems require.

### How do transaction IDs improve log analysis in distributed systems?

A **transaction ID** (or correlation ID) is a unique identifier generated at the entry point of a request and propagated through all downstream services. Including this ID in every log line allows operators to filter and join logs across multiple services and time windows, effectively reconstructing the complete request lifecycle without expensive full-text searches or complex heuristics.

### What is the 12-Factor App approach to log routing?

The 12-Factor methodology treats logs as event streams that applications write to stdout/stderr, never to files or remote sockets. The execution environment (Docker, Kubernetes, or systemd) captures these streams and handles routing, rotation, and shipping to aggregation platforms. This separation of concerns allows developers to focus on log content while operators control destination infrastructure without code changes.