Logging in Node.js
Logging is how a running Node.js program tells you what it is doing. During development that might be a quick console.log, but in a real service it means structured, leveled, machine-parseable log lines that get shipped to a file or log aggregator and searched later. Node gives you console out of the box, the lower-level streams underneath it, and a rich ecosystem of production logging libraries. This lesson covers how logging actually works under the hood, how to build your own structured logger, and how to use a production-grade logging library correctly.
Overview: How Logging Works in Node.js
console.log, console.info, console.debug, console.warn, and console.error are not magic — they are thin wrappers around two writable streams: process.stdout and process.stderr. console.log/info/debug write to stdout; console.warn/error write to stderr. This split matters a lot in production: shells, Docker, and Kubernetes let you redirect the two streams independently (node app.js 2> error.log), and most log-monitoring tools only alert on stderr. If you send everything through console.log, you lose that separation.
Before writing, console formats its arguments with util.inspect. Objects are pretty-printed with a default depth of 2 (deeper properties collapse to [Object]), and circular references are shown as [Circular *1] instead of throwing. You can also use printf-style substitutions: %s (string), %d (number), %j (JSON), and %o (generic object).
A subtlety worth knowing: whether console writes are synchronous or asynchronous depends on what stdout/stderr is connected to. On Linux and macOS, writes to a terminal (TTY) or a regular file are synchronous, but writes to a pipe (like when you run node app.js | some-other-program) are asynchronous. On Windows it is the opposite for files and pipes. In practice this means you should not assume console output appears in a guaranteed order relative to other async work, and high-volume logging has a real, measurable cost — every call pays for formatting plus a write syscall.
Structured logging
Free-text logs like "user 42 logged in" are fine for a script you’re watching in a terminal, but they don’t scale. Production systems use structured logging: each log entry is a JSON object with a timestamp, a severity level, a message, and arbitrary metadata fields, written one per line (this format is called NDJSON — newline-delimited JSON). A log aggregator (CloudWatch, Datadog, the ELK stack) can then index and query on fields like userId or statusCode instead of grepping strings.
Log levels are numeric so you can filter by severity: typically trace < debug < info < warn < error < fatal. Setting a minimum level (usually via an environment variable) lets you run verbose debug logs locally and quiet info-and-above logs in production, without touching code.
Where should logs go? Following the twelve-factor app convention, a Node process should treat logs as an unbuffered stream of events and simply write them to stdout/stderr. Let the runtime environment (Docker, systemd, PM2, Kubernetes) capture that stream and handle routing, rotation, and shipping — don’t have your app manage its own log files and rotation policy unless you have a specific reason to.
Syntax
console.log(message, ...substitutions);
console.error(message, ...substitutions);
process.stdout.write(string);
process.stderr.write(string);
logger.info(message, metaObject); // pattern used by structured loggers
| Method | Stream | Typical use |
|---|---|---|
console.log / console.info |
stdout | normal informational output |
console.debug |
stdout | verbose diagnostic output (same as console.log) |
console.warn |
stderr | recoverable problems, deprecations |
console.error |
stderr | errors and failures |
console.table |
stdout | tabular data during development |
console.time / console.timeEnd |
stdout | quick manual timing |
console.group / console.groupEnd |
stdout | indent related log lines |
Examples
Example 1: console methods in practice
const user = { id: 42, name: "Ada Lovelace" };
console.log("Server starting on port %d", 3000);
console.info("Loaded user:", user);
console.warn("Cache miss for key:", "session:42");
console.error("Failed to connect to database:", new Error("ECONNREFUSED"));
console.table([
{ id: 1, name: "Ada", role: "admin" },
{ id: 2, name: "Grace", role: "user" },
]);
console.group("Request lifecycle");
console.log("received");
console.log("processed");
console.groupEnd();
console.time("db-query");
for (let i = 0; i < 1e6; i++) {
// simulate work
}
console.timeEnd("db-query");
Server starting on port 3000
Loaded user: { id: 42, name: 'Ada Lovelace' }
Cache miss for key: session:42
Failed to connect to database: Error: ECONNREFUSED
at ...stack trace...
┌─────────┬────┬─────────┬─────────┐
│ (index) │ id │ name │ role │
├─────────┼────┼─────────┼─────────┤
│ 0 │ 1 │ 'Ada' │ 'admin' │
│ 1 │ 2 │ 'Grace' │ 'user' │
└─────────┴────┴─────────┴─────────┘
Request lifecycle
received
processed
db-query: 1.234ms
Notice that console.error automatically prints an Error's stack trace, and that console.warn/console.error lines would go to a completely different stream than the rest if you redirected output — that separation is invisible in a terminal but very visible once you pipe stdout and stderr separately.
Example 2: a small structured logger
Real applications usually want JSON log lines with levels you can filter at runtime, and they want errors to land on stderr. Here is a minimal, dependency-free implementation.
// logger.js
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 };
const currentLevel = LEVELS[process.env.LOG_LEVEL] ?? LEVELS.info;
function log(level, message, meta = {}) {
if (LEVELS[level] < currentLevel) return;
const entry = {
timestamp: new Date().toISOString(),
level,
message,
...meta,
};
const line = JSON.stringify(entry) + "\n";
if (level === "error" || level === "warn") {
process.stderr.write(line);
} else {
process.stdout.write(line);
}
}
export const logger = {
debug: (message, meta) => log("debug", message, meta),
info: (message, meta) => log("info", message, meta),
warn: (message, meta) => log("warn", message, meta),
error: (message, meta) => log("error", message, meta),
};
// app.js
import { logger } from "./logger.js";
logger.info("Server started", { port: 3000 });
try {
JSON.parse("{not valid json");
} catch (err) {
logger.error("Failed to parse config", {
error: { message: err.message, stack: err.stack },
});
}
{"timestamp":"2026-07-31T12:00:00.000Z","level":"info","message":"Server started","port":3000}
{"timestamp":"2026-07-31T12:00:00.010Z","level":"error","message":"Failed to parse config","error":{"message":"Unexpected token n in JSON at position 1","stack":"SyntaxError: ..."}}
The exact timestamps will differ every run, but the shape is fixed: one JSON object per line, with the error line written to stderr because its level is error. The metadata object is spread onto the entry after the base fields, so callers can attach arbitrary structured context (a port number, a nested error object) without the logger needing to know about it in advance.
Example 3: production logging with pino
Hand-rolled loggers are fine for small tools, but production services generally reach for a dedicated library. pino is one of the fastest Node.js loggers because it minimizes synchronous work on the main thread and can offload serialization/writing to a worker thread via transports.
npm install pino
// pino-logger.js
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
timestamp: pino.stdTimeFunctions.isoTime,
});
logger.info({ port: 3000 }, "Server started");
function handleRequest(req) {
const requestLogger = logger.child({ requestId: req.id });
requestLogger.info({ method: req.method, url: req.url }, "Incoming request");
try {
if (!req.url) throw new Error("Missing URL");
} catch (err) {
requestLogger.error({ err }, "Request failed");
}
}
handleRequest({ id: "abc-123", method: "GET", url: "/health" });
{"level":30,"time":"2026-07-31T12:00:00.000Z","pid":12345,"hostname":"box","port":3000,"msg":"Server started"}
{"level":30,"time":"2026-07-31T12:00:00.010Z","pid":12345,"hostname":"box","requestId":"abc-123","method":"GET","url":"/health","msg":"Incoming request"}
logger.child() creates a derived logger that automatically attaches fixed fields (like requestId) to every log call made through it — the standard way to correlate all the log lines produced while handling a single request. pino also has a built-in err serializer, so passing an Error object as { err } correctly expands its message, stack, and type, unlike raw JSON.stringify.
Under the Hood: Sync vs Async Logging
console.log calls process.stdout.write directly — it does not go through the microtask or timer queues. Whether that write blocks the event loop depends entirely on what stdout is connected to, as described above. A real structured logger that writes to a file using fs/promises, however, performs genuine asynchronous I/O: the write request is handed to libuv's thread pool, the event loop keeps servicing other requests while the write happens on a worker thread, and your await or callback resumes once the OS confirms the write. That is the difference between fs.appendFileSync (blocks the whole process) and fs.promises.appendFile (does not). High-performance loggers like pino go a step further and can run serialization and writing entirely in a separate worker thread via a transport, so logging has almost no cost on your application's main thread.
Common Mistakes
Mistake 1: blocking the event loop with synchronous log writes
Writing to a file synchronously inside a request handler blocks every other in-flight request for the duration of the disk write.
// BAD: blocks the event loop on every single request
import { appendFileSync } from "node:fs";
http.createServer((req, res) => {
appendFileSync("access.log", `${new Date().toISOString()} ${req.url}\n`);
res.end("ok");
});
Use the promise-based API (or a logging library that batches writes) instead, so the write doesn't stall other requests:
import { appendFile } from "node:fs/promises";
async function handleRequest(req, res) {
await appendFile("access.log", `${new Date().toISOString()} ${req.url}\n`);
res.end("ok");
}
Mistake 2: serializing Error objects with plain JSON.stringify
Error's message and stack properties are non-enumerable, so JSON.stringify silently drops them.
// BAD: Error objects don't serialize the way you'd expect
try {
riskyOperation();
} catch (err) {
console.log(JSON.stringify({ level: "error", err }));
// -> {"level":"error","err":{}} message and stack are gone
}
Pull the fields out explicitly, or use a logging library with a built-in error serializer (like pino's err serializer shown above):
try {
riskyOperation();
} catch (err) {
console.log(JSON.stringify({
level: "error",
message: err.message,
stack: err.stack,
}));
}
Other pitfalls to avoid
Using bare console.log for everything, with no levels or structure, means you cannot turn down noisy logs in production without redeploying code, and you cannot filter for just the errors when something goes wrong. And logging full request bodies, headers, or objects without thinking about it is a common way to accidentally ship passwords, API keys, or personal data straight into a third-party log aggregator — always log a redacted or minimal view of sensitive objects.
Best Practices
- Send normal output to stdout and warnings/errors to stderr, and preserve that split when you build custom loggers.
- Use structured (JSON) logs in any service whose output will be searched or alerted on — free text doesn't scale past a handful of log lines.
- Make the minimum log level configurable via an environment variable (for example
LOG_LEVEL), not hardcoded. - Attach a request ID or correlation ID to every log line produced while handling one request, so you can trace a single request's full lifecycle later.
- Never log secrets, passwords, tokens, or full unredacted request/response bodies.
- Prefer asynchronous, non-blocking writes (
fs/promises, or a logging library) over synchronous file writes in any hot code path. - Let the deployment platform (Docker, Kubernetes, systemd, PM2) capture and rotate stdout/stderr rather than managing your own log files unless you have a specific reason to.
- In production services, reach for a proven logging library (
pinoorwinston) instead of hand-rolling one — you get correct Error serialization, better performance, and integrations for free.
Practice Exercises
Exercise 1: Extend the logger.js module from Example 2 with a fatal level that, in addition to writing the log line, calls process.exit(1) after logging. Test it by setting LOG_LEVEL=debug and LOG_LEVEL=error and confirming which lines print.
Exercise 2: Build a small node:http server that logs every incoming request as one structured JSON line to stdout, containing the method, URL, response status code, and the time taken in milliseconds. Make sure the timing is measured from when the request arrives to when the response finishes (hint: listen for the response's finish event).
Exercise 3: Install pino and rewrite the server from Exercise 2 to use a pino logger with a logger.child() per request that attaches a random request ID (you can generate one with crypto.randomUUID()). Trigger a request that throws an error and confirm the error is logged with its message and stack intact.
Summary
console.log/info/debugwrite to stdout;console.warn/errorwrite to stderr — keep that separation in mind when redirecting or piping output.- Console I/O can be synchronous or asynchronous depending on what stream it's connected to (TTY, file, or pipe); don't assume perfect ordering with other async work.
- Structured (JSON) logging with levels and metadata scales far better than free-text logs once you need to search, filter, or alert on logs.
- Synchronous file writes (
appendFileSync) block the whole event loop; usefs/promisesor a real logging library for non-blocking writes. JSON.stringifysilently drops an Error'smessageandstackunless you extract them explicitly or use a logger with a built-in error serializer.- Production services should generally use a dedicated logging library like
pino, write to stdout/stderr, and let the deployment platform handle capture, rotation, and shipping. - Never log secrets, credentials, or unredacted personal data.
