Working with stdin, stdout & stderr
Every Node.js process is wired to three standard streams inherited from the operating system: standard input, standard output, and standard error. They are the foundation of command-line interaction — the channel through which a program receives data, reports results, and surfaces diagnostics. Understanding them lets you build scripts that compose cleanly with pipes, read user input interactively, and separate normal output from errors so tools and humans can both consume your program correctly.
The three standard streams
Node.js exposes the standard streams as properties on the global process object. Each is a Node.js stream backed by file descriptor 0, 1, or 2.
| Property | FD | Type | Purpose |
|---|---|---|---|
process.stdin | 0 | Readable stream | Reads input piped or typed into the process |
process.stdout | 1 | Writable stream | Normal program output |
process.stderr | 2 | Writable stream | Errors, warnings, and diagnostics |
Keeping output (fd 1) and errors (fd 2) on separate descriptors is what makes Unix tooling work: a user can redirect results to a file while still seeing errors on the terminal, e.g. node app.js > out.txt.
Writing to stdout and stderr
process.stdout and process.stderr are writable streams, so you write to them with .write(). Unlike console.log, .write() does not append a newline and does not format objects for you — you pass raw strings or buffers.
process.stdout.write('Processing...');
process.stdout.write(' done\n');
process.stderr.write('Warning: config file missing, using defaults\n');
Output:
Processing... done
Warning: config file missing, using defaults
Because .write() omits the trailing newline, it is ideal for progress indicators, spinners, or building output incrementally on a single line.
Relationship to console.log and console.error
The console methods are thin, convenient wrappers over the standard streams. console.log writes to process.stdout; console.error and console.warn write to process.stderr. Both append a newline and run their arguments through util.format, which is why you can pass objects and format specifiers.
console.log('User:', { id: 1, name: 'Ada' }); // -> stdout
console.error('Failed with code %d', 42); // -> stderr
Output:
User: { id: 1, name: 'Ada' }
Failed with code 42
Send diagnostics, logs, and errors to
stderr(viaconsole.error), and reservestdoutfor the program’s actual result. This keeps machine-readable output clean when someone pipes your tool into another command.
Reading from stdin
process.stdin is a readable stream. The most ergonomic way to consume all of it is to treat it as an async iterable — each iteration yields a chunk as the data arrives.
async function readStdin() {
let input = '';
process.stdin.setEncoding('utf8');
for await (const chunk of process.stdin) {
input += chunk;
}
return input;
}
const text = await readStdin();
const lines = text.trim().split('\n');
console.log(`Received ${lines.length} line(s)`);
Run it by piping data in:
printf 'one\ntwo\nthree\n' | node count.js
Output:
Received 3 line(s)
By default stdin is paused; iterating it (or attaching a data listener) resumes the flow. Setting the encoding to utf8 gives you strings instead of raw Buffer chunks.
Building interactive CLI prompts with readline
For interactive programs you rarely want raw chunks — you want to prompt the user line by line. The built-in node:readline/promises module wraps stdin/stdout into a clean question/answer interface.
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
const rl = createInterface({ input, output });
const name = await rl.question('What is your name? ');
const langs = await rl.question('Favorite languages (comma separated)? ');
console.log(`\nHi ${name}! You picked: ${langs.split(',').map(s => s.trim()).join(', ')}`);
rl.close();
Output:
What is your name? Ada
Favorite languages (comma separated)? JavaScript, Rust
Hi Ada! You picked: JavaScript, Rust
The CommonJS form is nearly identical — replace the imports with const { createInterface } = require('node:readline/promises').
You can also iterate stdin line by line with the callback-based node:readline module, which is handy for processing large piped files without buffering everything:
import { createInterface } from 'node:readline';
import { stdin as input } from 'node:process';
const rl = createInterface({ input, crlfDelay: Infinity });
let n = 0;
for await (const line of rl) {
n++;
if (line.includes('ERROR')) console.log(`Line ${n}: ${line}`);
}
The crlfDelay: Infinity option ensures Windows \r\n line endings are treated as a single break.
Detecting a TTY
Programs often behave differently when attached to a terminal versus when piped. Each stream exposes an isTTY boolean you can check — useful for deciding whether to enable colors or interactive prompts.
if (process.stdout.isTTY) {
console.log('\x1b[32mRunning interactively (colors on)\x1b[0m');
} else {
console.log('Output is piped or redirected');
}
When
stdinis not a TTY (data is being piped), interactiverl.question()prompts will not pause for a human. Always guard interactive flows withif (process.stdin.isTTY)and fall back to reading the piped stream.
Best Practices
- Use
console.logfor the program’s real result andconsole.errorfor logs, warnings, and errors so the two streams stay cleanly separated. - Reach for
process.stdout.write()(no newline) for progress bars, spinners, and incremental output; useconsole.logeverywhere else. - Consume
process.stdinwithfor awaitandsetEncoding('utf8')rather than manually wiringdata/endevent listeners. - Prefer
node:readline/promisesfor interactive prompts — it gives you cleanawait-ablequestion()calls and avoids callback nesting. - Guard interactive prompts with
process.stdin.isTTYand provide a non-interactive path for piped input. - Set
crlfDelay: Infinitywhen reading lines so cross-platform line endings are handled correctly. - Never
console.logsecrets or large objects tostdoutin scripts meant to be piped — it pollutes downstream parsing.