Console, readline & Terminal I/O
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were
checked against the official documentation on 2026-10-03, and every example below was run on all three lines unless the text says
otherwise. Almost every Node.js program talks to a terminal at some point: it prints results and diagnostics, it reads what the user
types, or it reads data piped in from another program. Four modules cover that ground, and all four are Stability 2 (Stable):
node:console (the global console), node:readline together with its promise flavour node:readline/promises, and node:tty. The
streams behind them, process.stdin, process.stdout and process.stderr, are documented with the process module. This page
explains how the pieces fit together and, above all, how a program should behave differently depending on whether a person or a pipe
is on the other end. For the stream mechanics themselves see Streams; for exit codes and signals see
Process, Signals & Exit Codes; the complete command-line tool that
assembles these pieces is built in Building CLI Tools. The running scenario is the Bookshelf
application that grows across this section, described in Introduction & Architecture.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
Stable (documented with |
Types depend on what the descriptor is connected to: TTY, pipe or socket, or file. |
|
2 Stable |
|
|
2 Stable |
Added in v17.0.0; the Promises API was marked stable in v24.0.0 and v22.17.0. |
|
Stable |
Added in v18.11.0. |
|
2 Stable |
|
|
Stable |
Stable since v23.5.0 and v22.13.0; respects |
The console
The global console is a ready-made Console instance wired to process.stdout and process.stderr. It looks like the browser console
and is deliberately compatible with it, but it is not the same thing: in Node.js its output goes to file descriptors, so the usual
Unix tools (redirection, pipes, tee) apply. The rule worth remembering is which stream each method uses. console.log(),
console.info() and console.debug() write to stdout; console.error(), console.warn(), console.trace() and the failure message
of console.assert() write to stderr. (console.debug() is an alias of console.log() since v8.10.0, there is no hidden "debug
level".) That split is what makes node app.js > result.txt keep the diagnostics on the screen.
Format specifiers in the first argument follow util.format(): %s (string), %d (number), %i (integer), %f (float), %j
(JSON), %o and %O (object inspection), %c (CSS, ignored in Node.js) and %% (a literal percent sign). Arguments left over after
the format string are appended, separated by spaces.
console.log('Bookshelf %s has %d books (%i%% read)', 'main', 2, 50);
console.log('%j and %o', { a: 1 }, [1, 2]);
console.log('%c styled?', 'color: red');
console.info('info goes to stdout');
console.warn('warn goes to stderr');
console.error('error goes to stderr');
console.log('extra', 'args', { nested: { deep: { deeper: { deepest: 1 } } } });
console.dir({ nested: { deep: { deeper: { deepest: 1 } } } }, { depth: 0 });
console.count('request'); console.count('request'); console.countReset('request'); console.count('request');
console.assert(1 + 1 === 3, 'math is %s', 'broken');
Bookshelf main has 2 books (50% read)
{"a":1} and [ 1, 2, [length]: 2 ]
styled?
info goes to stdout
warn goes to stderr
error goes to stderr
extra args { nested: { deep: { deeper: [Object] } } }
{ nested: [Object] }
request: 1
request: 2
request: 1
Assertion failed: math is broken
Three details are visible in the output. %o shows hidden details such as [length], while %O and the default formatting do not.
Objects nested deeper than two levels print as [Object] unless you ask for more with console.dir(obj, { depth: null }) (or
util.inspect() options). And console.assert() prints only when the condition is falsy, and it does not throw: unlike the assert
module it never stops the program.
Tables and groups
console.table(data[, properties]) renders an array of objects (or an object, a Map or a Set) as a box-drawn table, and the optional
second argument lists the columns to keep. console.group() increases the indentation of everything printed afterwards, by two spaces
per level, until the matching console.groupEnd(); console.groupCollapsed() is an alias of console.group() in Node.js.
import { readFile } from 'node:fs/promises';
const books = JSON.parse(await readFile(new URL('./books.json', import.meta.url), 'utf8'));
console.table(books);
console.table(books, ['title', 'year']);
console.table(new Map([['a', 1], ['b', 2]]));
console.group('Import');
console.log('reading');
console.group('Validate');
console.warn('2 warnings');
console.groupEnd();
console.groupEnd();
console.log('done');
┌─────────┬────┬─────────────────────┬──────────┬──────┬────────────────────┐
│ (index) │ id │ title │ author │ year │ tags │
├─────────┼────┼─────────────────────┼──────────┼──────┼────────────────────┤
│ 0 │ 1 │ 'Efficient Node.js' │ 'Buna' │ 2025 │ [ 'node', 'perf' ] │
│ 1 │ 2 │ 'Node.js Projects' │ 'Wexler' │ 2025 │ [ 'node' ] │
└─────────┴────┴─────────────────────┴──────────┴──────┴────────────────────┘
┌─────────┬─────────────────────┬──────┐
│ (index) │ title │ year │
├─────────┼─────────────────────┼──────┤
│ 0 │ 'Efficient Node.js' │ 2025 │
│ 1 │ 'Node.js Projects' │ 2025 │
└─────────┴─────────────────────┴──────┘
┌───────────────────┬─────┬────────┐
│ (iteration index) │ Key │ Values │
├───────────────────┼─────┼────────┤
│ 0 │ 'a' │ 1 │
│ 1 │ 'b' │ 2 │
└───────────────────┴─────┴────────┘
Import
reading
Validate
2 warnings
done
Grouping applies to stderr output too (the 2 warnings line above went to stderr, indented all the same). Tables are for humans: they
are wide, they truncate nothing and they are written to stdout, so never use them in a program whose output another program parses.
Timers and stack traces
console.time(label) starts a named timer, console.timeLog(label, …data) prints the elapsed time (plus any extra values) without
stopping it, and console.timeEnd(label) prints the elapsed time and stops it. The unit is chosen automatically (ms, s, …) since
v13.0.0, so a 3869 ms measurement prints as 3.869s. These timers are for quick, human-read measurements; for numbers you want to
store or compare use performance.now() or the node:perf_hooks module described in
The Event Loop. console.trace(message, …args) prints Trace: followed by the formatted message
and the current call stack to stderr, which answers "how did the program get here?" without a debugger.
import { setTimeout as sleep } from 'node:timers/promises';
console.time('load');
await sleep(120);
console.timeLog('load', 'half way', { rows: 10 });
await sleep(120);
console.timeEnd('load');
function findBook() { console.trace('lookup called with %s', 42); }
function handler() { findBook(); }
handler();
load: 121.236ms half way { rows: 10 }
load: 245.927ms
Trace: lookup called with 42
at findBook (file:///.../e3.mjs:7:31)
at handler (file:///.../e3.mjs:8:22)
at file:///.../e3.mjs:9:1
Calling console.timeEnd() with a label that is not running prints a warning instead of a duration, so keep labels unique per
operation when several overlap.
A custom Console
console.Console (also require('node:console').Console, or import { Console } from 'node:console') creates an independent console
that writes to any pair of writable streams. The options object accepts stdout, stderr, ignoreErrors (default true: errors
while writing are swallowed), colorMode (true, false or 'auto'), inspectOptions and groupIndentation (default 2). It is the
simplest way to send a subsystem’s output to a file, to a test buffer or to a second terminal without touching the global
console. The inspectOptions option can also be a Map from stream to options since v24.10.0, so that each stream gets its own
util.inspect() settings.
import { createWriteStream } from 'node:fs';
import { readFile, rm } from 'node:fs/promises';
import { Console } from 'node:console';
import { once } from 'node:events';
const out = createWriteStream('./app.log', { flags: 'a' });
await once(out, 'open');
const logger = new Console({ stdout: out, stderr: out, groupIndentation: 4, inspectOptions: { depth: 0 } });
logger.log('to file', { a: { b: 1 } });
logger.group('g');
logger.error('indented by four');
logger.groupEnd();
out.end();
await once(out, 'close');
console.log(await readFile('./app.log', 'utf8'));
await rm('./app.log');
to file { a: [Object] }
g
indented by four
A custom Console is a logging convenience, not a logging framework: there are no levels, no timestamps and no structured output. For
production logging see Diagnostic Reports, Logging & Observability.
Source: Node.js API: Class Console
Is console.log() synchronous?
It depends on where the output goes, and the Node.js documentation says so explicitly: the global console is neither consistently
synchronous like the browser’s nor consistently asynchronous like other Node.js streams. The behaviour is that of process.stdout:
| Connected to | POSIX (Linux, macOS) | Windows |
|---|---|---|
A file |
Synchronous |
Synchronous |
A terminal (TTY) |
Synchronous |
Asynchronous |
A pipe or socket |
Asynchronous |
Synchronous |
Synchronous writes avoid interleaved or lost output when the process calls process.exit(), but they block the event loop until the
write completes. Logging heavily to a pipe nobody reads, or to a slow terminal, can therefore stall a server; and on POSIX, a
process.exit() right after a large write to a pipe can truncate the output. Prefer process.exitCode and letting the loop drain
(see Process, Signals & Exit Codes).
stdin, stdout and stderr as streams
process.stdin is a readable stream on file descriptor 0, process.stdout a writable stream on descriptor 1 and process.stderr a
writable stream on descriptor 2. They are real Node.js streams, so everything in Streams applies:
pipeline(), async iteration, backpressure through the return value of write(), 'end' and 'error' events. Their concrete class
depends on what the descriptor is connected to, which is the first thing to know when something behaves differently in a terminal and
in a script.
| Connected to | process.stdin |
process.stdout |
|---|---|---|
A terminal |
|
|
A pipe |
|
|
A file ( |
|
|
The documentation describes process.stdin and process.stdout as a net.Socket (a Duplex stream) unless the descriptor refers to a
file. Two practical consequences follow. First, process.stdout.isTTY is true only for a terminal, and undefined otherwise, so it
is the portable "is a person watching?" test (the tty section below builds on it). Second, a pipe is a byte stream with no
message boundaries: a chunk read from process.stdin is not a line and may end in the middle of a multi-byte character, so set an
encoding with setEncoding() or decode with a TextDecoder, or use readline (next section) to get lines.
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
const upper = new Transform({
transform(chunk, _encoding, callback) {
callback(null, chunk.toString().toUpperCase());
},
});
console.error('stdin is a TTY:', process.stdin.isTTY ?? false);
await pipeline(process.stdin, upper, process.stdout);
printf 'hello\nworld\n' | node upper.mjs
stdin is a TTY: false
HELLO
WORLD
Source: Node.js API: stream.pipeline(), Node.js API: Class stream.Transform, Node.js API: process.stdin
The diagnostic went to stderr, the data went to stdout: the convention that makes node upper.mjs < in.txt > out.txt work. The
chunk.toString() call in the example is acceptable only because the transform is illustrative; a multi-byte character split across
two chunks would be corrupted, which is exactly the problem StringDecoder and setEncoding() exist to solve (see
Buffers & Binary Data).
Reading all of stdin is a one-liner with async iteration, and process.stdout.write() is the lower-level counterpart of
console.log() that adds no newline and returns a boolean: false means the internal buffer is above writableHighWaterMark
(65536 bytes by default here) and the program should wait for 'drain' before writing more.
import { stdin, stdout } from 'node:process';
let total = 0;
stdin.setEncoding('utf8');
for await (const chunk of stdin) total += chunk.length;
const ok = stdout.write(`read ${total} characters\n`);
console.error('write() returned', ok);
printf 'abcdef' | node count.mjs
write() returned true
read 6 characters
(The two lines come from different streams, so their order on a screen is not guaranteed when both are piped.)
When stdin is a terminal, for await (const chunk of stdin) waits until the user types Ctrl+D (end of input on POSIX); that is why
tools that accept both interactive and piped input check isTTY first and print a hint such as "reading from stdin, press Ctrl+D to
finish". The old "paused" mode described in the documentation (process.stdin.resume()) still exists for compatibility with scripts
written before Node.js v0.10; new code should use async iteration, pipeline() or readline.
readline and readline/promises
node:readline turns a readable stream into a sequence of lines and, when the input is a terminal, adds line editing, history and
tab completion. readline.createInterface(options) returns an Interface; the same function exists in node:readline/promises,
and the only difference is that rl.question() returns a promise there. The options that matter in practice:
| Option | Meaning |
|---|---|
|
The readable stream to listen to and the writable stream to write prompts to. If |
|
The string printed by |
|
Treat the streams as a TTY and use ANSI escape codes. Default: |
|
The line-history list. Default size 30; it only exists when |
|
Maximum delay between |
|
A function used for Tab completion. |
|
An |
Asking questions
await rl.question(query) prints the query, waits for the user to press Enter and resolves with the line (without the line ending).
Always close the interface when you are done: an open readline keeps stdin open, and with it the process alive. rl[Symbol.dispose]()
(an alias of rl.close(), since v23.10.0 and v22.15.0) lets using rl = createInterface(…) close it at the end of the block; observed:
that works on Node.js 26 and 24, while the using syntax itself is a SyntaxError on Node.js 22, where try/finally is still needed.
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const rl = createInterface({ input, output });
try {
const title = await rl.question('Title? ');
const year = Number(await rl.question('Year? '));
console.log({ title, year, valid: Number.isInteger(year) });
} finally {
rl.close();
}
Source: Node.js API: rl.question() (readline Promises API), Learn: Accept input from the command line in Node.js
Run in a terminal, this prints the two prompts, waits for two answers and prints the object. Run with piped input, however, it shows a well-known pitfall, observed identically on Node.js 26, 24 and 22:
printf 'Dune\n1965\n' | node ask.mjs
Title? Year? Warning: Detected unsettled top-level await at file:///.../ask.mjs:7
const year = Number(await rl.question('Year? '));
The first question() consumed the first line; the second line was already in the buffer and was emitted as a 'line' event while
nobody was listening, so it was dropped; then the input ended and the second question() never resolved. Interactive typing hides this
because the user is slower than the program. The reliable pattern for programs that must accept both is one async iterator for the whole
session, which buffers lines that arrive early.
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const rl = createInterface({ input, output });
const lines = rl[Symbol.asyncIterator]();
async function ask(query) {
output.write(query);
const { value, done } = await lines.next();
if (done) throw new Error('input ended before the answer to: ' + query);
return value;
}
const title = await ask('Title? ');
const year = Number(await ask('Year? '));
console.log({ title, year });
rl.close();
printf 'Dune\n1965\n' | node ask.mjs (identical on Node.js 26, 24 and 22)Title? Year? { title: 'Dune', year: 1965 }
Timeouts and cancellation
rl.question(query, { signal }) accepts an AbortSignal. When it aborts, the promise rejects with an AbortError (err.code is
'ABORT_ERR'), so a prompt can have a deadline with AbortSignal.timeout(). Asking a question after rl.close() returns a rejected
promise as well.
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const rl = createInterface({ input, output });
const signal = AbortSignal.timeout(300);
try {
const answer = await rl.question('Quick, a title? ', { signal });
console.log('got', answer);
} catch (err) {
console.log(`\n${err.name}: ${err.code}`);
} finally {
rl.close();
}
Quick, a title?
AbortError: ABORT_ERR
See Async Context & Cancellation for how AbortSignal is used across the platform.
Source: Node.js API: rl.question()
Event-driven prompts: prompt() and the 'line' event
For a REPL-like tool the event style is the natural fit: rl.setPrompt() or the prompt option sets the prompt, rl.prompt() prints
it, each submitted line arrives as a 'line' event, and 'close' fires on Ctrl+D, Ctrl+C or rl.close(). The 'SIGINT' event
lets a program intercept Ctrl+C on the interface instead of exiting.
import { createInterface } from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const shelf = ['Dune'];
const rl = createInterface({ input, output, prompt: 'bookshelf> ', historySize: 50 });
rl.prompt();
rl.on('line', (line) => {
const [command, ...rest] = line.trim().split(/\s+/);
switch (command) {
case 'add': shelf.push(rest.join(' ')); console.log(`added, ${shelf.length} books`); break;
case 'list': console.log(shelf.join('\n')); break;
case 'quit': rl.close(); return;
case '': break;
default: console.log(`unknown command: ${command}`);
}
rl.prompt();
}).on('close', () => {
console.log('bye');
});
printf 'add Emma\nlist\nfoo\nquit\n' | node menu.mjs (identical on Node.js 26, 24 and 22)bookshelf> added, 2 books
bookshelf> Dune
Emma
bookshelf> unknown command: foo
bookshelf> bye
Reading a file line by line
Reading a large text file one line at a time keeps memory flat, because only a buffer’s worth of data is held. Combine
fs.createReadStream() with readline and iterate with for await…of; set crlfDelay: Infinity so that Windows line endings
(\r\n) count as one break. Since v18.11.0 there is a shortcut: filehandle.readLines() from node:fs/promises creates the
readline interface for you. Both give the same lines, including empty ones and a final line with no trailing newline.
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({
input: createReadStream(new URL('./lines.txt', import.meta.url)),
crlfDelay: Infinity,
});
let n = 0;
for await (const line of rl) {
n += 1;
console.log(String(n).padStart(2), JSON.stringify(line));
}
import { open } from 'node:fs/promises';
const file = await open(new URL('./lines.txt', import.meta.url));
for await (const line of file.readLines()) {
console.log(JSON.stringify(line));
}
1 "first line"
2 "second line"
3 ""
4 "last without newline"
Two caveats from the documentation are worth testing yourself. readline.createInterface() starts consuming its input immediately,
so any asynchronous work between creating the interface and starting the for await loop can lose lines. Observed on Node.js 26,
24 and 22: a await sleep(100) between creating the interface over a file stream and the loop ended with the warning "Detected
unsettled top-level await", because the stream had already finished and the loop never saw the lines. Create the interface and
iterate in the same synchronous stretch of code. Second, iterating with for await is slower than the 'line' event, and the
documentation recommends the event for performance-sensitive code; break, throw or return inside the loop closes the interface and
therefore the input. For CSV, JSON Lines or logs where lines are further transformed, a stream pipeline
(Streams) is usually a better fit than a hand-written loop; for the file APIs themselves see
File System.
Masked input for secrets
Node.js has no built-in "password prompt". readline writes what the user types back to output so that line editing works, and the
standard technique is therefore to give it a custom writable as output that forwards everything to the real stdout, except while a
secret is being typed. The key points of the example below: the prompt is printed first, muted is switched on right after
question() has been called, terminal: true forces the line-editing mode even though output is not a TTY, and the interface is
always closed, which also restores the terminal.
import { createInterface } from 'node:readline/promises';
import { Writable } from 'node:stream';
import { stdin, stdout } from 'node:process';
async function askSecret(query) {
let muted = false;
const output = new Writable({
write(chunk, encoding, callback) {
if (!muted) stdout.write(chunk, encoding); // echo only while not muted
callback();
},
});
const rl = createInterface({ input: stdin, output, terminal: true });
try {
const pending = rl.question(query);
muted = true; // the prompt is already printed
return await pending;
} finally {
rl.close();
stdout.write('\n');
}
}
const secret = await askSecret('Passphrase: ');
console.log('length:', secret.length);
Passphrase:
length: 7
Three security notes follow, in prose because they are about behaviour rather than API. Never print or log the secret itself, as the
example prints only its length. A string cannot be wiped from memory, so keep it as briefly as possible and pass it directly to the
function that uses it (a key-derivation or authentication call, see Crypto, Passwords & Secrets); and do not accept
secrets as command-line arguments, which show up in ps output and shell history. The line-history list also records what was typed:
with terminal: true the interface keeps history, and the 'history' event gives a program access to it, so disable it for secret
prompts with historySize: 0 if the same interface is reused for other input. Finally, the technique requires a real terminal on
stdin; with piped input there is nothing to hide, and a tool that supports automation should read secrets from an environment
variable or a file loaded through --env-file (see
CLI Options, Environment & Config File).
Single keys and raw mode
readline works on lines. For menus, "press any key" prompts and games you need single key presses: readline.emitKeypressEvents(stream)
makes a readable stream emit 'keypress' events, and stdin.setRawMode(true) switches the terminal to raw mode, in which input is
available character by character with no echo and no line buffering. In raw mode Ctrl+C no longer raises SIGINT: it arrives as a key
(key.ctrl and key.name === 'c'), so the program must handle it, and it must restore the terminal with setRawMode(false) before
exiting. setRawMode() exists only on tty.ReadStream, so check stdin.isTTY first.
import { emitKeypressEvents } from 'node:readline';
import { stdin, stdout } from 'node:process';
if (!stdin.isTTY) {
console.error('needs an interactive terminal');
process.exit(1);
}
emitKeypressEvents(stdin);
stdin.setRawMode(true); // no echo, no line buffering, Ctrl+C is delivered as a key
stdout.write('press a key (q or Ctrl+C quits)\n');
stdin.on('keypress', (text, key) => {
if (key.name === 'q' || (key.ctrl && key.name === 'c')) {
stdin.setRawMode(false); // always restore the terminal
stdin.pause();
return;
}
console.log(`name=${key.name} sequence=${JSON.stringify(key.sequence)}`);
});
press a key (q or Ctrl+C quits)
name=a sequence="a"
name=up sequence="\u001b[A"
Since v26.8.0 setRawMode() also accepts the strings 'raw' and 'io' (binary-safe input with terminal output processing disabled;
not supported on Windows), and the new readStream.rawMode property reports the current mode; setRawMode(true) and setRawMode(false)
behave as before on every line. Run without a terminal, the program above prints the message and exits with code 1.
The tty module: is a person watching?
node:tty exposes two classes, tty.ReadStream and tty.WriteStream, which Node.js creates by itself for the standard streams when
they are connected to a terminal; the documentation says there is little reason to construct them manually. What you use daily is
the detection: process.stdout.isTTY (a boolean, true for terminals and undefined otherwise), or tty.isatty(fd), which takes a
file descriptor and returns false for anything that is not a terminal, including negative or non-integer numbers.
The decision to colour, animate or ask questions should always follow from that check, for each stream separately: stdout redirected to a file while stderr stays on the screen is a very common setup.
import { isatty } from 'node:tty';
import { stdout, env } from 'node:process';
console.log({
stdin: isatty(0), stdout: isatty(1), stderr: isatty(2),
columns: stdout.columns ?? null, rows: stdout.rows ?? null,
colorDepth: stdout.isTTY ? stdout.getColorDepth() : null,
hasColors256: stdout.isTTY ? stdout.hasColors(256) : false,
NO_COLOR: env.NO_COLOR ?? null, FORCE_COLOR: env.FORCE_COLOR ?? null,
notAFd: isatty(-1),
});
{
stdin: false, stdout: false, stderr: false,
columns: null, rows: null, colorDepth: null, hasColors256: false,
NO_COLOR: null, FORCE_COLOR: null, notAFd: false
}
{
stdin: true, stdout: true, stderr: true,
columns: 100, rows: 30,
colorDepth: 8, hasColors256: true,
NO_COLOR: null, FORCE_COLOR: null, notAFd: false
}
Source: Node.js API: tty.isatty(), Node.js API: writeStream.columns, Node.js API: writeStream.hasColors()
(The real output is one property per line; it is condensed here.) The properties of a tty.WriteStream:
| Member | Meaning |
|---|---|
|
The terminal size; kept up to date and announced by the |
|
|
|
|
|
Cursor movement and erasing. Each returns |
Colours and NO_COLOR
Do not hardcode ANSI escape sequences. util.styleText(format, text, options) produces them and, by default, checks that
process.stdout supports colours: its validateStream option (default true) and its stream option (default process.stdout)
make it return the plain text when the stream is not a terminal, when NO_COLOR or NODE_DISABLE_COLORS is set, and force colours when
FORCE_COLOR is set (since v22.8.0 and v20.18.0). Pass { stream: process.stderr } when the text is going to stderr, as stdout and
stderr can differ. FORCE_COLOR accepts 0 (no colours), 1 (16 colours), 2 (256) and 3 (16 million); it is also what
getColorDepth() honours. This is also the supported replacement for third-party packages such as chalk, kleur or ansi-colors;
the Node.js project publishes codemods for that migration.
import { styleText } from 'node:util';
import { stdout } from 'node:process';
console.log(styleText('green', 'ok'), styleText(['bold', 'red'], 'failed'));
console.log(JSON.stringify(styleText('green', 'ok')));
console.log(JSON.stringify(styleText('green', 'ok', { stream: stdout })));
console.log(JSON.stringify(styleText('green', 'ok', { validateStream: false })));
ok failed
"ok"
"ok"
"\u001b[32mok\u001b[39m"
In a terminal with TERM=xterm-256color the first line is written with escape codes, and so are the second and third lines (their
JSON form is "\u001b[32mok\u001b[39m"); with NO_COLOR=1 the output is the same as when piped, except for the fourth line, where
validation was switched off and the escape codes are always present.
The same variables make a colour policy for your own programs: honour NO_COLOR (any non-empty value disables colours, see
https://no-color.org), use colour only on a TTY, and offer --color or --no-color flags that map to FORCE_COLOR.
Hex colours ('#ff8800') in styleText() are available since v26.1.0 only; the 'none' format, a no-op that is convenient for
conditional styling, since v24.2.0 and v22.17.0.
Columns and cursor control: a progress bar
A progress bar shows all of the above in use. It draws only on a terminal, sizes itself from stdout.columns, redraws in place with
cursorTo(0) and clearLine(1) (erase to the right of the cursor), and falls back to a few plain lines when output goes to a file,
where carriage returns and escape codes would only litter a log.
import { stdout } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
const total = 20;
function render(done) {
const width = Math.max(10, Math.min(40, (stdout.columns ?? 80) - 12));
const filled = Math.round((done / total) * width);
return `[${'#'.repeat(filled)}${'.'.repeat(width - filled)}] ${String(Math.round((done / total) * 100)).padStart(3)}%`;
}
for (let i = 0; i <= total; i++) {
if (stdout.isTTY) {
stdout.cursorTo(0); // return to column 0 and redraw in place
stdout.write(render(i));
stdout.clearLine(1); // erase to the right of the cursor
} else if (i % 5 === 0) {
stdout.write(`progress ${i}/${total}\n`); // plain lines for logs and pipes
}
await sleep(10);
}
if (stdout.isTTY) stdout.write('\n');
[########################################] 100%
progress 0/20
progress 5/20
progress 10/20
progress 15/20
progress 20/20
The 'resize' event on process.stdout fires when the terminal size changes (the property stdout.columns is already updated when
it does), so long-running full-screen programs recompute their layout in a handler for it.
Putting it together: a well-behaved prompt
The pieces combine into one rule set for a command-line program: results go to stdout so they can be redirected or piped; prompts,
progress and diagnostics go to stderr; colour and interactivity depend on the stream being a TTY; an unrecoverable problem sets
process.exitCode and lets the loop finish. The Bookshelf "add a book" command below prompts on stderr, so that
node add.mjs > book.json captures only the JSON, validates each answer, colours the error message only if stderr is a terminal, and
works the same with piped answers.
import { createInterface } from 'node:readline/promises';
import process, { stdin, stdout, stderr } from 'node:process';
import { styleText } from 'node:util';
// Prompts and diagnostics go to stderr, the result goes to stdout: `add.mjs > book.json` stays clean.
const rl = createInterface({ input: stdin, output: stderr });
const lines = rl[Symbol.asyncIterator]();
async function ask(query, validate) {
for (;;) {
stderr.write(query);
const { value, done } = await lines.next();
if (done) throw new Error('input ended early');
const problem = validate(value.trim());
if (!problem) return value.trim();
stderr.write(styleText('red', ` ${problem}`, { stream: stderr }) + '\n');
}
}
try {
const title = await ask('Title: ', (v) => (v ? '' : 'a title is required'));
const year = Number(await ask('Year: ', (v) => (/^\d{4}$/.test(v) ? '' : 'use four digits')));
stdout.write(JSON.stringify({ title, year }) + '\n');
} catch (err) {
stderr.write(`${err.message}\n`);
process.exitCode = 1;
} finally {
rl.close();
}
printf 'Dune\n65\n1965\n' | node add.mjs 2>/dev/null # stdout only
printf 'Dune\n' | node add.mjs; echo "exit code $?" # input ends early
{"title":"Dune","year":1965}
Title: Year: input ended early
exit code 1
The complete tool, with argument parsing, --help, exit codes and packaging, is the subject of
Building CLI Tools.
Source: Node.js API: Readline
What changed recently
| Version | Change |
|---|---|
v26.8.0 |
|
v26.1.0 |
|
v24.10.0 |
|
v24.2.0, v22.17.0 |
|
v24.0.0, v22.17.0 |
The |
v23.10.0, v22.15.0 |
|
v23.5.0, v22.13.0 |
|
v22.8.0, v20.18.0 |
|
v18.11.0 |
|
v17.0.0 |
|
Nothing in this area was removed or changed incompatibly between Node.js 22 and 26 in the documentation reviewed on 2026-10-03; everything that is new is additive.
Where the books are outdated
Node.js Projects (Jonathan Wexler, O’Reilly, 2025) builds its first command-line application on node:readline, wrapping the callback
rl.question() in a hand-written promise or in util.promisify() so that it can be used with await. That was necessary before
Node.js v17.0.0; today node:readline/promises provides it directly, with AbortSignal support the wrappers lack. The book’s prompts also
share the weakness shown above: sequential questions read from piped input can lose lines, which an application that is meant to be
scripted should avoid with the single-iterator pattern. Its imports omit the node: prefix, which still works but is the older
spelling. Efficient Node.js (Samer Buna, O’Reilly, 2025) uses console.time()/console.timeEnd() and process.stdin/process.stdout piping
accurately to illustrate asynchronous behaviour and streams; the points to add are the pipeline() function over bare pipe() calls (so
that errors propagate and streams are cleaned up) and the TTY-versus-pipe differences described on this page, which the examples do not need
but real tools do. Neither book covers util.styleText() (stable since v23.5.0), so colour is something to take from this page rather than
from a third-party package.