Back

Logging Requests with Node.js Middleware

Logging Requests with Node.js Middleware

When something breaks in your API at 2am, the first thing you reach for is your logs. If they’re missing, incomplete, or buried in noise, debugging becomes guesswork. HTTP request logging in Node.js is one of those fundamentals that’s easy to get wrong—and expensive to ignore.

This article covers how request logging middleware works in Express, when to use established libraries like Morgan or Pino, and what production-ready logging actually looks like.

Key Takeaways

  • Logging middleware in Express intercepts requests early in the chain and listens for the res finish event to capture response data like status codes and duration.
  • Morgan provides quick, human-readable access logs suited for development, while Pino offers fast, structured JSON output built for production environments.
  • Use AsyncLocalStorage in modern Node.js to propagate correlation IDs across async operations without manually threading them through every function call.
  • Never log sensitive data such as Authorization headers, cookies, or request bodies by default—use built-in redaction or sanitize manually.

How Logging Middleware Fits Into the Express Request Lifecycle

In Express, middleware is a function with the signature (req, res, next). It intercepts every request before it reaches your route handler. Logging middleware sits at the top of that chain, recording what came in and what went out.

[Client] → [Logging Middleware] → [Auth Middleware] → [Route Handler] → [Response]

The key insight: you can’t log the full response—status code, duration—until the response is finished. That’s why logging middleware listens to the res finish event rather than logging immediately.

import crypto from 'node:crypto'

// Express middleware for request logging
const logRequests = (req, res, next) => {
  const start = Date.now()

  res.on('finish', () => {
    logger.info({
      method: req.method,
      url: req.url,
      status: res.statusCode,
      duration: Date.now() - start,
      requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
    })
  })

  next()
}

app.use(logRequests)

Note that crypto.randomUUID() requires importing the node:crypto module (or using the global crypto available in Node.js 19+). Also, the logger object here is a placeholder—you would replace it with your actual logging library instance (such as Pino or console).

This pattern works across any Node.js HTTP server, not just Express.

Traditional Access Logging with Morgan

Morgan is the classic Express request logging middleware. Two lines and you have Apache-style access logs:

import morgan from 'morgan'

app.use(morgan('combined'))
// Output example:
// ::1 - - [01/Jan/2025:00:00:00 +0000] "GET /api/users HTTP/1.1" 200 1234

Morgan is fine for development and simple deployments. Its output is human-readable but not easily machine-parseable—which becomes a problem when you’re shipping logs to Datadog, Loki, or any structured log system.

Structured Logging in Node.js with Pino

For production, structured logging means emitting JSON. Every log line becomes a queryable record. Pino is the standard choice here—it’s significantly faster than Winston or Bunyan, with minimal overhead.

The pino-http package drops in as Express middleware:

import express from 'express'
import pinoHttp from 'pino-http'

const app = express()

app.use(pinoHttp({
  level: process.env.LOG_LEVEL ?? 'info',
  redact: ['req.headers.authorization', 'req.headers.cookie'],
}))

app.get('/', (req, res) => {
  req.log.info('handling root request')
  res.send('ok')
})

Pino writes to stdout. Your infrastructure (Docker, systemd, a log shipper) handles routing those lines to wherever they need to go.

Correlation IDs and Request Context

When you’re tracing a request across multiple async operations, you need a consistent request ID attached to every log line. In modern Node.js, use AsyncLocalStorage for this—not the deprecated domain module or low-level async hooks.

import { AsyncLocalStorage } from 'node:async_hooks'
import crypto from 'node:crypto'

export const requestContext = new AsyncLocalStorage()

app.use((req, res, next) => {
  const requestId = req.headers['x-request-id'] ?? crypto.randomUUID()
  requestContext.run({ requestId }, next)
})

Any logger call inside that request’s async context can now pull the requestId without threading it through every function manually. Here’s how you would retrieve it:

// In any downstream module
import { requestContext } from './context.js'

function doWork() {
  const { requestId } = requestContext.getStore()
  logger.info({ requestId, msg: 'doing work' })
}

Logging Responsibly

A few rules that matter in production:

  • Never log Authorization headers, cookies, or API tokens. Use Pino’s redact option or sanitize manually before writing.
  • Be careful with client IPs behind proxies. req.socket.remoteAddress will give you the proxy’s IP. If your app is behind a reverse proxy, configure Express’s trust proxy setting correctly and treat X-Forwarded-For headers carefully.
  • Don’t log request bodies by default. They can be large, binary, or contain PII. Log them selectively, with size limits.

Choosing Between Morgan and Pino

MorganPino (pino-http)
Output formatText (Apache-style)JSON (structured)
PerformanceGoodExcellent
Log redactionManualBuilt-in
Production-readyLimitedYes
Setup time~2 min~5 min

Use Morgan for local development if you prefer readable output. Use Pino for anything that ships to production.

Conclusion

Good logging middleware is invisible until you need it—and then it’s everything. Start with pino-http, emit structured JSON to stdout, and let your infrastructure handle routing and storage. Pair it with correlation IDs via AsyncLocalStorage so you can trace any request end to end. Keep sensitive data out of your logs from day one, and you’ll have an observability foundation that scales with your application.

FAQs

Yes, you can run both side by side. A common pattern is using Morgan in development for readable console output and Pino in production for structured JSON logs. Use an environment variable to conditionally apply one or the other so you avoid duplicate log entries in any single environment.

Pino writes JSON to stdout by design. Use a log shipper like Fluent Bit, Filebeat, or the Datadog Agent to collect stdout output from your container or process and forward it to your logging platform. This keeps your application code decoupled from any specific log destination.

AsyncLocalStorage propagates context automatically across the entire async call chain without modifying function signatures. Manually passing a request ID through every function is error-prone and clutters your code. AsyncLocalStorage is stable in Node.js 16 and above and is the recommended approach for request-scoped context.

With Pino, the overhead is typically negligible because it uses fast serialization and writes logs efficiently to stdout. Morgan is also lightweight for most workloads. The `finish` event listener pattern means logging runs after the response has been handed off to the operating system, so it usually doesn't affect client latency. Avoid synchronous file writes or heavy serialization in your logging path.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay