Node.js Logging: Console vs Libraries, Express and Morgan
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.debugis an alias forconsole.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
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.debugas a debug switch. It is an alias forconsole.logand cannot be turned off. - Logging
error.messageonly. Keep the stack, and serialize it yourself becauseJSON.stringify(new Error())is empty. - Interpolating data into the message.
console.log(`user ${id} failed`)makes every line unique. Keepmsgconstant and adduser_id. - Two loggers writing different formats. Morgan text lines next to JSON lines break parsing.
- Logging request bodies and headers.
Authorizationand 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
- 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)
- 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)
- 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)
- 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)
- util.format specifiers such as %s and %j, with %j replacing circular references with [Circular] (accessed Nov 8, 2026)
- 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)