console.log is great in dev. In production it’s a disaster:
- Blocks the event loop on a slow terminal/file.
- No log levels — can’t filter “warn and above.”
- Unstructured strings — grep works but querying (“show me all 500s in the last hour”) doesn’t.
- No timestamps unless we add them manually.
- No request correlation — can’t follow one request across many log lines.
In simple language: console.log is a printf, not a logger. We need a logger.
Structured (JSON) logs > free-text
Pre-cloud: tail -f app.log | grep ERROR. Post-cloud: logs go to Datadog/Loki/CloudWatch/ELK and get queried. Those systems work way better with JSON.
// Free-text — hard to query
2026-05-26 12:34:56 ERROR: user 42 failed login from 1.2.3.4
// Structured — every field is queryable
{"level":"error","time":1716720896000,"msg":"failed login","userId":42,"ip":"1.2.3.4"}
Now level:error AND userId:42 is a one-liner in any log system.
Pino — fast and JSON-first
Pino is the de facto standard for new Node services. Async, structured by default, very fast (claims ~5x faster than Winston in their benchmarks).
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
});
logger.info('server started');
logger.info({ port: 3000, env: 'prod' }, 'listening');
logger.error({ err, userId }, 'failed to load user');
Note the argument order: object first, message second. The object’s keys become top-level fields in the log line.
Output:
{"level":30,"time":1716720896,"pid":42,"hostname":"app-1","port":3000,"env":"prod","msg":"listening"}
level: 30 is info (10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal).
Child loggers for request context
app.use((req, res, next) => {
req.log = logger.child({ reqId: crypto.randomUUID() });
next();
});
app.get('/users/:id', async (req, res) => {
req.log.info({ userId: req.params.id }, 'fetching user');
// every log line in this request includes reqId automatically
});
Now we can trace one request through five log lines by filtering on reqId.
Pretty-print in dev
Raw JSON in dev is ugly. Pipe through pino-pretty:
node server.js | pino-pretty
Or configure it conditionally:
const logger = pino({
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
});
Winston — flexible, more batteries included
Winston has been around longer. More transports out of the box (files, HTTP, Slack, Loggly). More configurable formatters. Slower than Pino but rarely the bottleneck.
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'error.log', level: 'error' }),
],
});
Pino vs Winston
| Pino | Winston | |
|---|---|---|
| Speed | Very fast | Slower |
| Default format | JSON | Configurable |
| Transports | Worker-thread based | Built-in zoo |
| Best for | New services, microservices, high throughput | Legacy projects, complex routing needs |
For new projects in 2026, default to Pino unless we have a specific reason for Winston.
Log levels — actually use them
trace— extremely verbose, “entered this function”debug— dev-only detailsinfo— normal lifecycle events (“server started”, “job processed”)warn— something unexpected but not brokenerror— request failed, operation failedfatal— process is dying
Default to info in prod, debug in dev. Letting debug lines into prod logs makes them noisy and expensive.
What to log (and what NOT to)
Log:
- Every incoming request (URL, status, latency, requestId, userId)
- Every error with stack trace
- Job/cron start and end
- External API calls (target, duration, status)
Never log:
- Passwords, tokens, API keys, session IDs
- Full credit card numbers, PII (unless legally OK and you have redaction)
- The full request body of every request (huge volume, often contains PII)
Use a redaction config:
const logger = pino({
redact: ['req.headers.authorization', 'password', '*.token'],
});
Where logs go
In containerized environments (Docker, Kubernetes), log to stdout/stderr only. Don’t write log files inside the container. The orchestrator captures stdout and routes it to your log backend. Files inside containers vanish when the container restarts.
For VMs/bare metal, write to stdout and let systemd-journald / a sidecar agent ship them off.