The console Module
Every Node.js program has access to console without importing anything — it is a global, always-available object. But it is not magic: console is a real instance of the Console class exported by the built-in node:console module, built on top of Node’s streams and the util.inspect() formatter. It is the tool you reach for constantly during development — printing values, tracing execution order, timing slow operations, and rendering tabular data.
This lesson goes beyond console.log() to cover the full API: the different log levels and which stream each one writes to, format specifiers, console.table(), grouping and timers, and how to build your own Console instance that logs to a file instead of the terminal.
Overview / How it works
The global console is an instance of console.Console, pre-configured to write to process.stdout and process.stderr. You can require it explicitly — const console = require("node:console") — and you get back the exact same global object, not a copy. Requiring it is mostly useful when you want access to the Console constructor to build a custom logger, covered below.
The key thing to internalize is that console methods are split across two separate output streams:
console.log(),console.info(),console.debug(),console.table(),console.dir(), andconsole.group*()all write to stdout.console.error(),console.warn(),console.trace(), andconsole.assert()all write to stderr.
This split is deliberate, not cosmetic. It lets you redirect the two streams independently in a shell: node app.js > out.log captures only normal output, while node app.js 2> err.log captures only errors and warnings. Docker, systemd, and most log collectors also treat stdout and stderr as separate channels, so using the right method matters for how your logs get routed in production.
When you call console.log("User: %s, age: %d", name, age) with a string containing % specifiers, Node formats the message internally using the same logic as util.format(). When you pass an object with no format string, Node instead runs it through util.inspect(), which recursively renders properties, colorizes output when connected to a TTY, and limits recursion depth (default depth of 2) so large nested objects do not flood your terminal.
Synchronous or asynchronous? It depends on the destination
A subtlety that surprises a lot of Node developers: whether a console write blocks the event loop depends on where the output is going, not on the method you call. Per Node’s documented behavior for process.stdout/process.stderr:
| Destination | POSIX (Linux/macOS) | Windows |
|---|---|---|
Regular file (redirected with >) |
Synchronous | Synchronous |
| TTY (an interactive terminal) | Synchronous | Asynchronous |
Pipe (e.g. node app.js | less) |
Asynchronous | Synchronous |
This matters most on POSIX systems: if your program pipes its output into another process and then calls process.exit() immediately after logging a lot of data, the still-pending asynchronous writes can be dropped before they flush. We cover the fix in Common Mistakes below.
Syntax
The general shape of the most common calls:
console.log(message[, ...args]);
console.error(message[, ...args]);
console.table(data[, columns]);
console.group([label]);
console.groupEnd();
console.time(label);
console.timeEnd(label);
console.assert(value, message[, ...args]);
| Method | Stream | Purpose |
|---|---|---|
console.log() / console.info() / console.debug() |
stdout | Normal output; info and debug are aliases of log. |
console.warn() / console.error() |
stderr | Warnings and errors; warn is an alias of error. |
console.table(data, columns?) |
stdout | Renders an array of objects as an ASCII table. |
console.group() / console.groupEnd() |
stdout | Indents all subsequent output until groupEnd() is called. |
console.time(label) / console.timeEnd(label) |
stdout | Starts/stops a named timer and prints the elapsed time. |
console.count(label?) |
stdout | Counts how many times it has been called with a given label. |
console.assert(value, msg) |
stderr | Logs a message only when value is falsy — it does not throw. |
console.dir(obj, options?) |
stdout | Like log, but exposes util.inspect options such as depth and colors. |
console.trace(msg?) |
stderr | Prints a message plus the current stack trace. |
Format specifiers you can use inside the first string argument:
| Specifier | Meaning |
|---|---|
%s |
String |
%d / %i |
Integer |
%f |
Floating-point number |
%j |
JSON (falls back to [Circular] for cycles) |
%o / %O |
Object, formatted via util.inspect() |
%% |
A literal percent sign |
Examples
Example 1: Log levels and format specifiers
console.log("Simple message");
console.log("User: %s, Age: %d", "Ada", 32);
console.info("Server started on port %d", 3000);
console.debug("Cache size: %d entries", 128);
console.error("Something went wrong: %s", "connection refused");
console.warn("Deprecation notice: %s", "config.json format v1 is deprecated");
Output:
Simple message
User: Ada, Age: 32
Server started on port 3000
Cache size: 128 entries
Something went wrong: connection refused
Deprecation notice: config.json format v1 is deprecated
All six lines show up in a terminal because a terminal displays both stdout and stderr, but only the first four would be captured by node app.js > out.log — the error and warn lines go to stderr and would only show up with 2> or 2>&1.
Example 2: Tables, grouping, and timers
const users = [
{ id: 1, name: "Ada Lovelace", role: "admin" },
{ id: 2, name: "Alan Turing", role: "editor" },
{ id: 3, name: "Grace Hopper", role: "editor" },
];
console.group("User report");
console.table(users, ["name", "role"]);
console.groupEnd();
console.time("sum-loop");
let total = 0;
for (let i = 0; i < 1_000_000; i++) {
total += i;
}
console.timeEnd("sum-loop");
console.log("Total:", total);
Output:
User report
┌─────────┐
│ (index) │ name │ role │
├─────────┤
│ 0 │ Ada Lovelace │ admin │
│ 1 │ Alan Turing │ editor │
│ 2 │ Grace Hopper │ editor │
└─────────┘
sum-loop: 2.481ms
Total: 499999500000
console.group() indents every line printed until the matching console.groupEnd(), including the table. The exact millisecond value from console.timeEnd() will vary run to run — it is a real wall-clock measurement, useful for quick performance sanity checks but not a substitute for a proper benchmarking tool.
Example 3: A custom Console instance that logs to files
const { Console } = require("node:console");
const { createWriteStream } = require("node:fs");
const accessLog = createWriteStream("access.log", { flags: "a" });
const errorLog = createWriteStream("error.log", { flags: "a" });
const logger = new Console({ stdout: accessLog, stderr: errorLog });
logger.log("[%s] GET /users 200", new Date().toISOString());
logger.error("[%s] DB connection failed", new Date().toISOString());
accessLog.on("error", (err) => console.error("access log write failed:", err));
errorLog.on("error", (err) => console.error("error log write failed:", err));
console.log("Wrote entries to access.log and error.log");
Output:
Wrote entries to access.log and error.log
The global console is just one particular Console instance wired to process.stdout/process.stderr. Nothing stops you from creating another instance wired to any writable streams — here, two file streams. This is exactly how simple file-based loggers work before you reach for a dedicated logging library. Notice the error listeners on each stream: any writable stream can emit error (a full disk, a permissions problem), and an unhandled error event on a stream crashes the process.
Under the hood: order of operations
Walking through Example 2 step by step clarifies what actually happens:
console.group("User report")runs synchronously and increases an internal indentation counter — nothing is printed yet.console.table(users, [...])builds the full ASCII table string in memory (viautil.inspectinternals), applies the current indentation, then writes it to stdout.console.groupEnd()decrements the indentation counter synchronously.console.time("sum-loop")stores a high-resolution timestamp (viaprocess.hrtime()under the hood) keyed by the label"sum-loop"in an internal map.- The
forloop runs entirely synchronously — it blocks the event loop for its duration, which is fine here because it is short and CPU-only. console.timeEnd("sum-loop")looks up the stored timestamp, computes the difference, formats it, prints it, and deletes the label from the internal map — callingtimeEnd()again with the same label without a newtime()call would print a warning instead.
All of this happens on the main thread, in program order — console methods do not queue work onto the microtask queue or the libuv thread pool the way fs.promises or crypto.pbkdf2 do. The only asynchrony that can occur is at the I/O layer, in how the underlying stream flushes bytes to the OS (see the sync/async table above).
Common Mistakes
Mistake 1: Logging errors with console.log instead of console.error
try {
JSON.parse("{ invalid json");
} catch (err) {
console.log("Error: " + err.message);
}
This works visually in a terminal, but it silently breaks stream separation: shell redirection, Docker log drivers, and PM2/systemd all treat stdout as "normal output" and stderr as "something to alert on." An error printed with console.log() is invisible to anything watching stderr for failures.
try {
JSON.parse("{ invalid json");
} catch (err) {
console.error("Error parsing config:", err.message);
}
Mistake 2: Calling process.exit() right after heavy output
function dumpReport(rows) {
for (const row of rows) {
console.log(row);
}
process.exit(0);
}
On POSIX systems, writes to a pipe (for example node report.js | less) are asynchronous. Calling process.exit() immediately after a burst of console.log() calls can terminate the process before the OS has finished flushing those writes, silently truncating the output. This is one of the most-reported "console.log randomly cuts off" bugs in Node.
function dumpReport(rows) {
for (const row of rows) {
console.log(row);
}
process.exitCode = 0;
}
Setting process.exitCode lets Node exit naturally once the event loop has no more work — including flushing pending writes — instead of forcing an immediate, potentially lossy exit.
Mistake 3: Assuming console.assert() throws like an assertion library
console.assert(1 === 2, "This will just print a warning, not throw");
console.log("Execution continues after a failed assertion");
Output:
Assertion failed: This will just print a warning, not throw
Execution continues after a failed assertion
Unlike Node's node:assert module, console.assert() never throws and never stops execution — a failed assertion only logs a message to stderr. If you need to actually halt on a bad invariant, use assert() from node:assert, not console.assert().
Best Practices
- Use
console.error()/console.warn()for anything indicating a problem, and reserveconsole.log()/console.info()for normal output — this keeps shell redirection and log collectors meaningful. - Prefer format specifiers (
console.log("User %s logged in", id)) over string concatenation — they are clearer and letutil.inspectformat objects safely, including cyclic ones. - Reach for
console.table()when debugging arrays of similar objects — it is far easier to scan than a wall of nested{ }output. - Use
console.time()/console.timeEnd()for quick, informal performance checks instead of manually diffingDate.now()calls. - Never call
process.exit()immediately after a large burst of output; setprocess.exitCodeand let Node exit naturally so writes flush. - In production services, prefer a real structured-logging library (
pino,winston) over rawconsolecalls — they give you log levels, JSON output, and log rotation thatconsoledoes not. - Never log secrets, tokens, or full request bodies containing personal data — treat log output as something that will end up somewhere persistent.
- When you need file-based logging without a full library, build a small
new Console({ stdout, stderr })wrapper rather than reinventing formatting.
Practice Exercises
- Write a script that builds an array of at least four product objects (
name,price,inStock) and prints them withconsole.table(), wrapped in aconsole.group("Inventory")/console.groupEnd()pair. - Write an
asyncfunction that wraps a slow operation (e.g. asetTimeout-based delay) withconsole.time()/console.timeEnd(), and, usingtry/catch, logs any thrown error withconsole.error()including the error'sstackproperty. - Create a custom
Consoleinstance whosestdoutandstderrpoint at two different files usingfs.createWriteStream(). Log three normal messages and two error messages through it, then explain in a comment whyprocess.exitCode(rather thanprocess.exit()) is the safer way to end that script.
Summary
consoleis a global instance of theConsoleclass fromnode:console— no import required, but importable for building custom instances.log/info/debug/table/group*write to stdout;error/warn/trace/assertwrite to stderr — pick the right one for correct shell redirection and log routing.- Format specifiers (
%s,%d,%o,%j) beat string concatenation for clarity and safe object formatting. console.table(),console.group(), andconsole.time()/timeEnd()make debugging structured or slow data much faster than plainlog()calls.- Writes can be synchronous or asynchronous depending on whether the destination is a file, a TTY, or a pipe — calling
process.exit()right after heavy output can truncate it; preferprocess.exitCode. console.assert()only logs on failure — it never throws or stops execution, unlikenode:assert.- For production services, a real logging library (
pino,winston) is a better fit than rawconsolecalls.
