Learn how to write better logs in Node.js — structured JSON, correct log levels, PII safety, trace IDs, and shipping logs to a centralized service.
Most Node.js applications log poorly. They use console.log scattered everywhere, mix human-readable strings with structured data, and never get logs off the box. When production goes down at 2 AM, this costs you hours.
Here are the logging practices that actually make a difference.
Plain text logs like "User 123 logged in from 1.2.3.4" are nearly impossible to analyze at scale. You can't filter by user ID, you can't count login attempts by IP, and you can't join with other data.
Structured logs attach data as fields:
// Bad
console.log(`User ${userId} logged in from ${ip}`)
// Good
logger.info('user.login', {
userId,
ip,
userAgent: req.headers['user-agent'],
duration: Date.now() - startTime,
})
Every field becomes searchable and filterable in your log management tool. In LogFlow's Logs Explorer, you can write userId:123 to find all events for a specific user.
Node.js logging libraries typically support five levels. Use them correctly:
| Level | When to use |
|---|---|
debug |
Development only — variable values, function entry/exit, detailed flow. Never in production unless debugging a specific issue. |
info |
Normal operations — server started, user logged in, job completed, payment processed. Should be few enough to read manually. |
warn |
Unexpected but handled — deprecated API used, fallback triggered, retry attempted, rate limit near. |
error |
Something failed and needs attention — unhandled exception, API call failed, database error. |
fatal |
Application cannot continue — startup failure, required service unreachable, data corruption detected. |
The most common mistake is using error for everything. If you logger.error() on every 404 response, your alerts become noise and you miss real errors.
Logs are often less strictly controlled than your database. They may flow through multiple systems — your log shipper, the log service, your team's Slack notifications. Log these and you'll have a bad day:
If you need to log user context, log IDs, not values. If you must log something sensitive for debugging, use a staging environment with fake data.
// Bad
logger.info('Processing payment', { cardNumber: req.body.cardNumber, cvv: req.body.cvv })
// Good
logger.info('Processing payment', { last4: req.body.cardNumber.slice(-4), userId: req.user.id })
console.log writes to stdout. In a containerized environment, that output goes... somewhere. Maybe a Docker volume, maybe Kubernetes aggregates it, maybe it disappears on container restart.
Centralize logs to a dedicated service like LogFlow so they persist, are searchable, and can trigger alerts:
import { createLogger } from '@getlogflow/js'
const logger = createLogger({
apiKey: process.env.LOGFLOW_API_KEY,
service: 'api',
environment: process.env.NODE_ENV,
})
export default logger
Now import this logger everywhere instead of console.log. See our tutorial for Express.js or Next.js for setup guides.
Without trace IDs, you can't connect related log lines. When a user reports an error, you need to find all logs for their specific request across multiple services.
Use AsyncLocalStorage to propagate a trace ID through the entire request lifecycle:
import { AsyncLocalStorage } from 'async_hooks'
import { randomUUID } from 'crypto'
const requestContext = new AsyncLocalStorage()
// Middleware — adds traceId to every request
export function traceMiddleware(req, res, next) {
const traceId = req.headers['x-trace-id'] || randomUUID()
res.setHeader('x-trace-id', traceId)
requestContext.run({ traceId }, next)
}
// Logger — automatically includes traceId
function log(level, message, data = {}) {
const ctx = requestContext.getStore()
logger[level](message, {
...data,
traceId: ctx?.traceId,
})
}
In LogFlow, you can then search traceId:abc-123 to see every log line from that request, even across microservices. See our guide on distributed tracing for multi-service setups.
You don't need to log everything — that creates noise and costs money. Log at the boundaries where things can go wrong or where you'll need to reconstruct what happened:
Avoid logging inside tight loops or on every cache hit — that's just noise.
Node.js process managers like PM2 send SIGTERM before killing a process. If your logger buffers logs in memory (for performance), you must flush on shutdown or lose the last few seconds of logs — which are often the most important.
const logger = createLogger({
apiKey: process.env.LOGFLOW_API_KEY,
service: 'api',
})
process.on('SIGTERM', async () => {
logger.info('SIGTERM received — shutting down')
await logger.flush()
process.exit(0)
})
process.on('SIGINT', async () => {
await logger.flush()
process.exit(0)
})
LogFlow's SDK flushes automatically on process exit, but explicitly handling shutdown signals is good practice regardless.
High-traffic applications can generate gigabytes of debug or info logs per day. Not all of them are worth keeping. A 10% sample of successful request logs gives you enough for latency analysis while cutting storage costs by 90%.
function shouldLog(level, path) {
if (level === 'error' || level === 'fatal') return true
if (path === '/health' || path === '/metrics') return false
if (level === 'info') return Math.random() < 0.1 // 10% sample
return false
}
See our post on log sampling strategies for a more detailed approach.
Should I use Winston, Pino, or just console.log?
For production applications, use Pino. It's the fastest Node.js logger (benchmarks show 5-10x faster than Winston) and outputs structured JSON by default. Winston is more flexible but slower and requires more configuration to output proper JSON. console.log is fine for scripts but not for production services where you need levels, structured data, and transport control.
How do I log in Next.js API routes?
Next.js API routes are standard Node.js functions. Create a singleton logger in lib/logger.ts and import it in your route handlers. For App Router edge functions, check that your logger supports the Edge Runtime (no Node.js builtins). See our Next.js logging tutorial for a complete setup.
What should I do with unhandled rejections and exceptions?
Always log them and exit. An unhandled promise rejection usually means your application is in an inconsistent state. Log as fatal, flush, and let your process manager restart the process:
process.on('unhandledRejection', (reason) => {
logger.fatal('Unhandled rejection', { reason })
logger.flush().then(() => process.exit(1))
})
How much does logging cost?
It depends on your log volume. Verbose debug logging in production can generate hundreds of GB per month. With LogFlow's Growth plan at $49/month for 100 GB, structured logging for a mid-size application is quite affordable. The key is not logging health check endpoints and sampling verbose levels.
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeWhat is Log Management? A Complete Guide
Log management is the process of collecting, storing, and analyzing log data from your applications and infrastructure. Here's everything you need to know.
Debugging Microservices with Distributed Tracing
Trace IDs connect logs across services. Learn how to implement distributed tracing without heavy infrastructure.
How Log Sampling Can Cut Your Logging Costs by 80%
Not all logs are equal. Drop health checks, sample debug noise, and keep what matters — without losing visibility.