Skip to content
Node.js nd process 4 min read

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.

PropertyFDTypePurpose
process.stdin0Readable streamReads input piped or typed into the process
process.stdout1Writable streamNormal program output
process.stderr2Writable streamErrors, 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 (via console.error), and reserve stdout for 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 stdin is not a TTY (data is being piped), interactive rl.question() prompts will not pause for a human. Always guard interactive flows with if (process.stdin.isTTY) and fall back to reading the piped stream.

Best Practices

  • Use console.log for the program’s real result and console.error for 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; use console.log everywhere else.
  • Consume process.stdin with for await and setEncoding('utf8') rather than manually wiring data/end event listeners.
  • Prefer node:readline/promises for interactive prompts — it gives you clean await-able question() calls and avoids callback nesting.
  • Guard interactive prompts with process.stdin.isTTY and provide a non-interactive path for piped input.
  • Set crlfDelay: Infinity when reading lines so cross-platform line endings are handled correctly.
  • Never console.log secrets or large objects to stdout in scripts meant to be piped — it pollutes downstream parsing.
Last updated June 14, 2026
Was this helpful?