Updated Nov 5, 20266 min read

Node.js Logging in 2026: Pino, Winston and Structured Logs

How to log properly in a Node.js backend: why console.log falls short, structured JSON logging with Pino, when Winston fits, request context with AsyncLocalStorage, redaction and levels, and how logs connect to traces.

Most Node.js services start with console.log, and it holds up until the first production incident where someone has to find every line belonging to one failed checkout across several instances. At that point the logs need levels, consistent fields and a request or trace ID on every line, which console does not provide.

Node has no structured logger in its standard library, so the choice is between libraries such as Pino and Winston, or a framework that provides one. This guide covers how they differ, the practices that make logs useful whichever you pick, and how Encore (a tool that works alongside your framework) handles logging for TypeScript services.

Node.js logging options at a glance

Encore encore.dev/logPinoWinstonconsole
OutputStructured, streamed and in tracesNewline-delimited JSONConfigurable formatsPlain text
FieldsObject of key-value pairsMerge objectMetadata objectNone
Request contextTrace ID, service, endpoint, user ID attachedChild loggersChild loggersManual
RedactionNot automatic, keep secrets out of fieldsPath-based redact optionCustom formatNone
OverheadLowVery low, worker-thread transportsHigherSynchronous writes in some cases

Structured logs tied to traces with Encore

Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. Its logger, encore.dev/log, takes a message and a fields object, and records each line in the trace of the request that produced it, next to the API calls, database queries and Pub/Sub messages Encore traces on its own, as described in the TypeScript logging docs.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create checkout --example=ts/empty$ cd checkout && mkdir payments

There is no logger to configure or pass around, errors go in as the first argument, and log.with() carries shared fields:

// payments/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("payments");
// payments/payments.ts import { api } from "encore.dev/api"; import log from "encore.dev/log"; interface ChargeParams { orderId: string; amountMinor: number; currency: string; } export const charge = api( { expose: true, method: "POST", path: "/charges" }, async (p: ChargeParams): Promise<void> => { const logger = log.with({ orderId: p.orderId }); try { await authorize(p); logger.info("payment authorized", { event: "payment.authorized", amountMinor: p.amountMinor, }); } catch (err) { logger.error(err, "payment authorization failed", { event: "payment.failed", amountMinor: p.amountMinor, currency: p.currency, }); throw err; } }, ); async function authorize(p: ChargeParams): Promise<void> { if (p.amountMinor <= 0) throw new Error("amount must be positive"); }

Encore adds the service, endpoint, trace ID and authenticated user ID to every line, and the error's type, message, stack and cause are recorded from the error object. Under encore run, each log appears inside its request's trace in the local development dashboard. On AWS, container logs go to CloudWatch organized by service, and on GCP to Cloud Logging. The minimum level is set with log_level in encore.app, and without it every level including trace is emitted.

When an AI coding agent investigates a bug report, it can open the failing request's trace and read the log lines in order with the database queries around them, which beats searching raw output.

Log lines are also attached to the request's trace, as covered in the tracing docs, and in your cloud they go to CloudWatch on AWS or Cloud Logging on GCP, per the AWS and GCP infrastructure docs.

Best fit: TypeScript teams who want structured logs with trace and request context on every line.

Pino

Pino writes one JSON object per line and keeps work on the main thread small, moving formatting and shipping into transports that run in worker threads. Child loggers bind fields such as a request ID, and the redaction option replaces values at given paths before they are written:

import pino from "pino"; const logger = pino({ level: process.env.LOG_LEVEL ?? "info", redact: ["req.headers.authorization", "*.password"], }); const reqLogger = logger.child({ requestId: "req_8f2a" }); reqLogger.info({ orderId: "ord_42", amountMinor: 14900 }, "payment authorized"); try { throw new Error("card declined"); } catch (err) { reqLogger.error({ err, orderId: "ord_42" }, "payment authorization failed"); }

Note the argument order: the fields object comes first and the message second. Locally, piping output through pino-pretty makes it readable. Fastify uses Pino as its built-in logger and exposes a per-request child as request.log.

Good to know

Pino's default err serializer applies to an error passed as the first argument or under the err key, so logging an error under another key loses its stack.

Winston

Winston is built around formats and transports: a logger can write JSON to stdout, a file and an HTTP endpoint at once, with each transport at its own level. It suits applications that need several destinations or custom formatting:

import winston from "winston"; const logger = winston.createLogger({ level: "info", format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json(), ), transports: [new winston.transports.Console()], }); logger.child({ requestId: "req_8f2a" }).info("payment authorized", { orderId: "ord_42" });

Good to know

In containers, logging to stdout and letting the platform ship logs is usually simpler than Winston's file or network transports.

Request context with AsyncLocalStorage

Instead of passing a child logger through every function, AsyncLocalStorage from node:async_hooks keeps a value for the duration of an async call chain, so middleware can store a request-scoped logger once:

import { AsyncLocalStorage } from "node:async_hooks"; import pino, { type Logger } from "pino"; const base = pino(); const store = new AsyncLocalStorage<Logger>(); export function withRequestLogger(requestId: string, fn: () => Promise<void>) { return store.run(base.child({ requestId }), fn); } export function getLogger(): Logger { return store.getStore() ?? base; }

Adding the OpenTelemetry trace ID to that child logger is what links log lines to traces in tools that support it.

Practices that matter more than the library

  • Keep messages stable and put variable data in fields, so events can be grouped and counted.
  • Reserve error for failures that need attention, and log expected outcomes such as validation failures at a lower level.
  • Add an event field such as payment.failed for anything that alerts or dashboards rely on.
  • Never log passwords, tokens, cookies, authorization headers or whole request bodies.
  • Log to stdout in containers and let the platform collect it.

Frequently asked questions

What is the best logging library for Node.js?

Pino is the common default for backend services: it writes newline-delimited JSON with low overhead, supports child loggers and path-based redaction, and has pino-pretty for readable local output. Winston is more configurable, with multiple transports and custom formats, at a higher cost per log call.

Why not use console.log in production?

console.log writes unstructured text with no levels, no consistent fields and no request context, so logs cannot be filtered or aggregated without parsing. Structured loggers write JSON with a level, timestamp, message and fields that log platforms can index.

How do I add a request ID to every log line in Node.js?

Create a child logger with the request ID per request and store it in AsyncLocalStorage from node:async_hooks in middleware, then read it from the store wherever you log. Frameworks such as Fastify do this with request.log out of the box.

How do I get structured logs with trace IDs in a TypeScript API without configuring a logger?

With Encore, import log from encore.dev/log and call log.info or log.error with a message and a fields object, and use log.with() for shared fields. Encore attaches the service, endpoint, trace ID and authenticated user ID to every line and records each log in the request's trace.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders