Guides
Guide

Node.js Logging: Console vs Libraries, Express and Morgan

By Sazabi Team

Last verified on Nov 8, 2026.

Node.js gives you console for free, and it is enough while you develop. In production you need levels, JSON fields, request IDs, and output that does not block the event loop. This guide explains what console does, how to write structured logs without a dependency, how to log Express requests with morgan, and how to choose a library.

What console Actually Does

The global console writes to process.stdout and process.stderr. The Node.js docs say console.log and console.info go to stdout, and console.warn and console.error go to stderr. Two details surprise people:

  • console.debug is an alias for console.log. It goes to stdout and is never filtered, so a "debug" line is always on.
  • The docs warn that console methods are neither consistently synchronous nor consistently asynchronous. It depends on the platform and what the stream is attached to.

Objects are formatted for humans. Run this and the nested object is cut off:

console.log('order placed', { order_id: 4812, nested: { a: { b: { c: { d: 1 } } } } })
// order placed { order_id: 4812, nested: { a: { b: [Object] } } }

And errors lose their contents when serialized:

console.log(JSON.stringify({ err: new Error('boom') }))
// {"err":{}}

Neither is acceptable in a log you need to search at 3 a.m. util.format specifiers such as %s and %j help a little (%j swaps circular references for [Circular]), but you still have one unstructured string with no level, timestamp, or request ID.

Why Express Says to Move Off console

The Express performance guide says console.log() and console.error() are synchronous when the destination is a terminal or a file, so they are not suitable for production unless you pipe the output to another program. It recommends the debug module for debugging output and a logging library such as Pino for application activity. Piping the output to another program, such as a log collector, is the other half of that advice.

A Structured Logger in Fifteen Lines

You can get most of the benefit without installing anything. This writes JSON, honors a level threshold, and serializes errors:

const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 }
const threshold = LEVELS[process.env.LOG_LEVEL || 'info']

function log(level, msg, fields = {}) {
  if (LEVELS[level] > threshold) return
  const line = { time: new Date().toISOString(), level, msg, ...fields }
  if (line.err instanceof Error) {
    line.err = { type: line.err.name, message: line.err.message, stack: line.err.stack }
  }
  const out = level === 'error' || level === 'warn' ? process.stderr : process.stdout
  out.write(JSON.stringify(line) + '\n')
}

log('info', 'order placed', { order_id: 4812 })
log('error', 'payment failed', { order_id: 4812, err: new Error('card declined') })
{ "time": "2026-11-08T06:56:25.786Z", "level": "info", "msg": "order placed", "order_id": 4812 }

This is the shape of structured logging. It has no redaction, no child loggers, and no transports, which is exactly where a library earns its place.

Choosing a Library

NeedPickNotes
JSON output, minimal setupPinoNamed by the Express docs for application activity. Has redact and child().
A pipeline of formats and several destinationsWinstonComposable formats, per-transport levels, exception handlers.
Debug output you can switch on by environment variabledebug moduleRecommended by Express for debugging only.
HTTP request linesmorgan, or pino-http with PinoCovered below.

Whichever you pick, keep one logger module for the process, read the level from LOG_LEVEL, and write to stdout. See log levels for the level meanings, and the logging best practices guide for field naming.

Express Request Logs With morgan

Morgan is Express middleware that writes one line per request. Its predefined formats such as combined produce Apache-style text, which is hard to query. The README documents a format function that receives tokens, req, and res and returns a string, so return JSON:

const express = require('express')
const morgan = require('morgan')
const { randomUUID } = require('node:crypto')

const app = express()

app.use((req, res, next) => {
  req.id = req.get('x-request-id') || randomUUID()
  res.set('X-Request-ID', req.id)
  next()
})

morgan.token('id', (req) => req.id)

app.use(
  morgan(
    (tokens, req, res) =>
      JSON.stringify({
        time: new Date().toISOString(),
        level: res.statusCode >= 500 ? 'error' : 'info',
        msg: 'http_request',
        request_id: tokens.id(req, res),
        method: tokens.method(req, res),
        path: tokens.url(req, res),
        status: Number(tokens.status(req, res)),
        duration_ms: Number(tokens['response-time'](req, res)),
        user_agent: tokens['user-agent'](req, res),
      }),
    { skip: (req) => req.path === '/healthz' }
  )
)
{
  "time": "2026-11-08T06:56:00.408Z",
  "level": "info",
  "msg": "http_request",
  "request_id": "req-8f3a",
  "method": "GET",
  "path": "/order",
  "status": 200,
  "duration_ms": 0.306,
  "user_agent": "node"
}

Three morgan details are worth knowing. The skip option removes health checks from the stream. The immediate option logs on request instead of response, so you see requests even if the server crashes, but status and response size are then unavailable. And a request that is cut off before a response goes out has an empty status, which the Number() call above turns into NaN, which JSON.stringify writes as null. Treat a null status as an aborted request, not a bug.

Morgan logs requests only. Application events still go through your own logger, and the shared request_id is what joins them: it is your correlation ID.

Log Errors in One Place

Express's default error handler sends the stack trace to the client outside production, which is not where you want it. Add an error-handling middleware, which takes four arguments, and log once there:

app.use((err, req, res, next) => {
  log('error', 'unhandled_error', { request_id: req.id, err })
  res.status(500).send('error')
})

Place it after all routes. Logging in the handler and again at each catch gives you duplicate lines for one failure.

Common Mistakes

  • Using console.debug as a debug switch. It is an alias for console.log and cannot be turned off.
  • Logging error.message only. Keep the stack, and serialize it yourself because JSON.stringify(new Error()) is empty.
  • Interpolating data into the message. console.log(`user ${id} failed`) makes every line unique. Keep msg constant and add user_id.
  • Two loggers writing different formats. Morgan text lines next to JSON lines break parsing.
  • Logging request bodies and headers. Authorization and cookies end up in your logs.

Production Tips

  • Set NODE_ENV=production, which the Express docs tie to hiding stack traces from clients.
  • Write to stdout and let the platform collect it.
  • Add trace IDs when you adopt OpenTelemetry. The OpenTelemetry Node.js guide shows how, and the trace ID then appears next to request_id.
  • For structured field conventions, read the JSON logging guide.

Sending Node.js Logs to Sazabi

Sazabi, an AI-native observability and security platform, accepts Node.js logs and traces through an OpenTelemetry SDK with OTLP exporters, as described in its OpenTelemetry source docs. Those docs advise adding an explicit log record processor and exporter if traces arrive without logs, and the OpenTelemetry Node.js guide shows the setup. See the OpenTelemetry integration, and client errors for watching the 4xx and 5xx lines morgan produces.

Frequently Asked Questions

Is console.log fine for logging in Node.js?

It is fine in development. For production, Express recommends a logging library because console.log is synchronous when it writes to a terminal or file, and it gives you no levels or JSON fields.

What does morgan do?

Morgan is Express middleware that writes one line per HTTP request, with method, URL, status, and response time. It logs requests only, not application events.

How do I get JSON request logs from morgan?

Pass morgan a format function that returns JSON.stringify of an object built from its tokens, such as method, url, status, and response-time.

Which Node.js logging library should I use?

Pino and Winston are the two common choices. Choose Pino for JSON-first logging with minimal setup, and Winston when you want its format and transport pipeline.

Sources

  1. The global console writes to process.stdout and process.stderr, log and info go to stdout and warn and error to stderr, console.debug is an alias for console.log, and its sync or async behavior depends on the backing stream (accessed Nov 8, 2026)
  2. console.log and console.error are synchronous when the destination is a terminal or a file, so Express recommends the debug module for debugging and a logging library such as Pino for app activity (accessed Nov 8, 2026)
  3. The default error handler sends the stack trace to the client outside production, and custom error-handling middleware takes four arguments (accessed Nov 8, 2026)
  4. morgan format functions receive tokens, req and res, the skip and immediate options, the default stream is process.stdout, and tokens can be created (accessed Nov 8, 2026)
  5. util.format specifiers such as %s and %j, with %j replacing circular references with [Circular] (accessed Nov 8, 2026)
  6. Node.js apps send logs and traces to Sazabi through an OpenTelemetry SDK with OTLP exporters; when traces arrive without logs, an explicit log record processor and exporter should be added (accessed Nov 8, 2026)