Engineering

Node.js Logging Best Practices in 2026

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.

LogFlow TeamMay 28, 20268 min read

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.

1. Use Structured JSON Logs

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.

2. Choose the Right Log Level

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.

3. Never Log Sensitive Data

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:

  • Passwords and password hashes
  • Credit card numbers (even partial)
  • Social Security numbers or national IDs
  • Session tokens and API keys
  • Full email addresses of private users
  • Raw authentication tokens (Bearer headers)

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 })

4. Centralize Logs with a Logging Service

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.

5. Add Trace IDs to Every Request

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.

6. Log at Application Boundaries

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:

  • Request start and end — with method, path, status code, duration
  • External API calls — both the request and response (or error)
  • Database queries — slow queries (> 500ms), errors
  • Background job start, success, and failure
  • Authentication events — login, logout, token refresh, failures
  • State transitions — order placed, payment processed, email sent

Avoid logging inside tight loops or on every cache hit — that's just noise.

7. Flush Logs on Shutdown

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.

8. Sample Verbose Logs in Production

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.

Frequently Asked Questions

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.

Start monitoring your logs today

Free plan available. No credit card required. Up and running in 2 minutes.

Get started free