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.
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.

Run in your terminal to get started locally.
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 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.
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 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" });
In containers, logging to stdout and letting the platform ship logs is usually simpler than Winston's file or network transports.
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.
error for failures that need attention, and log expected outcomes such as validation failures at a lower level.event field such as payment.failed for anything that alerts or dashboards rely on.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.
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.
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.
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.