Node.js Standard I/O: File Descriptors, TTYs, Pipes, and Backpressure
Every Node process begins with three standard I/O descriptors already open. Descriptor 0 is exposed as process.stdin, descriptor 1 as process.stdout, and descriptor 2 as process.stderr. The shell or parent process decides what those descriptors point to before your JavaScript starts: a terminal, a pipe, a file, a socket, or another inherited handle. Node then wraps each descriptor in a stream whose behavior depends on that target.
stdin, stdout, and stderr
The separation is old Unix design, but it still shapes everyday CLI code. Standard output carries program data. Standard error carries diagnostics. When those two streams stay separate, a shell pipeline can process data from stdout while warnings, progress messages, and stack traces remain visible on stderr.
That clean split does not make the streams simple. The familiar properties on process sit on top of file descriptors that were configured before Node started. The property names are stable, but the backing handle changes with the way the command was launched. The same process.stdout.write() call may target an interactive terminal, a downstream process, or a regular file.
Node classifies and wraps those descriptors lazily when the corresponding standard stream is first accessed. After that point, .write(), .on('data'), and .pipe() look like ordinary stream operations, but the chosen wrapper still affects blocking behavior, buffering, terminal-only methods, and shutdown rules.
The three streams
At the JavaScript layer, process.stdin is a Readable stream, using the stream mechanics covered earlier in the streams material. process.stdout and process.stderr are Writable streams. Each one maps back to the standard file descriptors covered in the file-descriptor material and inherited from the parent process.
Standard streams carry a few rules that ordinary stream examples can hide. A Readable returned by fs.createReadStream() follows file-stream behavior. A socket Writable follows socket behavior. The standard streams start by inspecting fd 0, fd 1, and fd 2, then choose TTY, pipe, or file handling. From that choice come the details that count later: they can block, they can buffer, and they can lose buffered data if process.exit() cuts the process off.
process.stdout.write('hello');
process.stderr.write('debug info');Both calls write a string to a Writable stream, but they do not serve the same audience. When someone runs node app.js | grep foo, the pipe receives stdout only. stderr still goes to the terminal unless the shell redirects fd 2 as well. That is how machine-readable output stays separate from warnings, stack traces, progress lines, and debug noise.
The same split explains much of the etiquette around command-line tools. A command that prints JSON should put only JSON on stdout. Timing information, skipped-file messages, parse warnings, and progress bars belong on stderr. Then a caller can run node tool.js > data.json and receive a clean file while diagnostics still appear in the terminal because fd 2 stayed connected there.
Later, custom Console instances can redirect that mapping somewhere else entirely. The useful starting point is that the global streams follow the inherited descriptors.

Figure 1 — A Node process inherits separate routes for standard input, standard output, and standard error. Keeping those routes independent lets data flow through pipelines while diagnostics stay on their own channel.
process.stdin
process.stdin starts in paused mode. No input flows until code attaches a listener, resumes the stream, or pipes it somewhere. Once reading starts, stdin holds a ref on the event loop, so active input keeps the process alive.
The smallest version attaches a data listener:
process.stdin.on('data', (chunk) => {
console.log(`Got: ${chunk}`);
});The data event delivers Buffer chunks. With terminal input, chunks usually arrive one line at a time because the terminal driver buffers input until Enter. Piped input has looser edges: echo "hello" | node script.js might produce one chunk, while a larger producer might produce many. A JSONL parser therefore cannot assume that one chunk equals one line, and a protocol parser cannot assume that one chunk equals one message. Accumulate bytes until a complete unit is available, then parse that unit.
The async iterator form uses the same stream underneath:
for await (const chunk of process.stdin) {
console.log(`Got: ${chunk}`);
}This form removes some event wiring, but it reads the same bytes from the same stream. The loop finishes when stdin ends: Ctrl+D on Unix, Ctrl+Z on Windows, or EOF from a piped source.
By default, those chunks are raw bytes. chunk.toString() gives you text because Buffer.prototype.toString() defaults to utf8. Binary tools should keep the Buffer. Text tools can call process.stdin.setEncoding('utf8'), which makes data events deliver strings directly.
Line-by-line with readline
Many interactive CLI tools want complete lines rather than arbitrary chunks. The readline module sits on top of stdin and does that buffering:
import { createInterface } from 'node:readline';
const rl = createInterface({
input: process.stdin,
output: process.stdout
});
rl.on('line', (line) => {
console.log(`You said: ${line}`);
});The interface buffers bytes, decodes text, and splits on newline breaks (\n or \r\n). With TTY input and output, it also cooperates with terminal editing behavior such as backspace, arrow keys, and history navigation. With a pipe, there is no terminal editor involved; readline simply splits the incoming bytes into lines.
The promise API uses the same underlying idea and gives prompt-style code a more direct shape:
import { createInterface } from 'node:readline/promises';
const rl = createInterface({
input: process.stdin,
output: process.stdout
});
try {
const answer = await rl.question('Your name? ');
console.log(`Hello, ${answer}`);
} finally {
rl.close();
}rl.question() writes the prompt to stdout, waits for one line, and resolves with the string. Closing the interface in finally releases readline's ref on stdin even if prompt handling throws.
Raw mode
When stdin is attached to a terminal, it can also switch into raw mode:
if (!process.stdin.isTTY) process.exit(1);
const stop = () => { process.stdin.setRawMode(false); process.exit(); };
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.on('data', (key) => {
if (key[0] === 3) stop(); // Ctrl+C
process.stdout.write(key);
});Raw mode makes keystrokes arrive immediately. The terminal stops line buffering, and your program receives bytes directly and decides whether to echo anything back. Ctrl+C arrives as 0x03 instead of becoming SIGINT, so the handler owns that behavior. Password prompts, menu UIs, REPL controls, and editor-style input all depend on this mode.
That method exists on TTY ReadStream instances. Pipe-backed and file-backed stdin usually have no terminal mode and no setRawMode method, so an unguarded call throws a TypeError before it can change anything.
Raw mode does not turn every key into a single byte. The up arrow sends \x1b[A; down sends \x1b[B; right sends \x1b[C; left sends \x1b[D. Your code receives a 3-byte Buffer for those keys. Libraries such as keypress and readline's internal key parser decode those ANSI escape sequences, and raw-mode tools that handle arrows need that parser somewhere.
Raw mode also raises the cost of bad cleanup. If a program enables raw mode and then crashes before restoring the original mode, the user's terminal can be left without line buffering, visible echo, or normal Ctrl+C handling. Well-behaved terminal programs restore cooked mode in finally blocks and signal handlers. Node restores the original TTY mode during normal shutdown, but code that calls process.exit() from random places can still make cleanup harder to reason about.
Because active stdin holds a ref on the event loop, attaching a data listener can keep the process open. That is correct for interactive programs. Some tools only want optional keyboard input while the main job runs, and those tools should let the process exit when the main job finishes.
process.stdin.resume();
if (typeof process.stdin.unref === 'function') {
process.stdin.unref();
}After unref(), stdin can still receive input, but it stops keeping the event loop alive. The method exists on TTY and pipe-backed stdin. File-backed stdin can be a plain fs.ReadStream, so production code should guard the call. The process exits when all other ref'd work finishes. You can call process.stdin.ref() later if an interactive prompt becomes active.
Developer tools often use that pattern: start with stdin unref'd, then ref it when the user enters an interactive debugging prompt. Non-interactive runs can then exit normally.
process.stdout
process.stdout is the Writable stream for program output. console.log() writes to it, and so does process.stdout.write().
console.log('hello'); // writes "hello\n"
process.stdout.write('hello'); // writes "hello" (no newline)console.log() first calls util.format() on its arguments, appends a newline, and then writes the formatted string to process.stdout. process.stdout.write() writes the bytes you pass. For prompts, progress bars, terminal UI, and output that needs exact newline control, the direct stream call gives you the narrower operation.
That formatting step is real work. console.log('count: %d', 42) handles printf-style placeholders. console.log({ a: 1 }) calls util.inspect(). console.log('a', 'b', 'c') joins arguments with spaces. All of that runs synchronously before stdout sees a chunk.
In hot paths, that cost can count as much as the write itself. A debug branch that calls console.log(obj) pays inspection cost before stdout sees anything. For command-line tools that print a few dozen lines, the cost is usually noise. For tight loops, logging cost often includes formatting and terminal I/O as much as the write() syscall.
Once the chunk reaches the stream, stdout follows the normal Writable contract. write() returns true when the internal buffer remains below the highWaterMark. It returns false when the buffer has crossed that threshold, and more writes should wait for drain.
const bigChunk = Buffer.alloc(1024 * 1024);
const ok = process.stdout.write(bigChunk);
if (!ok) {
process.stdout.once('drain', () => {
// safe to write again
});
}Most CLI output ignores the return value because a few log lines or status messages rarely build enough pressure to count. A formatter that dumps megabytes to stdout is different. If the consumer reads slowly and your code keeps writing, Node's internal buffer grows until the consumer catches up or the heap gives out.
In Node v24, the default byte-stream highWaterMark is 64 KiB, and pipe-backed stdout follows that default unless code overrides it. For TTY-backed stdout on Unix, synchronous writes usually leave no pending stream buffer after each call, so that threshold rarely enters the picture.
Backpressure shows up quickly once stdout points somewhere other than the terminal. node dump.js | gzip > out.gz can make stdout wait on compression. node dump.js | head -10 can close the pipe early. node dump.js > /mnt/slow/out.txt can block on filesystem latency. The JavaScript call is the same, but the descriptor target decides the pressure.
The return value is the early signal a tight loop gets before memory starts growing. If the loop ignores false, it can enqueue thousands of chunks while the downstream process is still reading the first few. Node will accept those chunks into the Writable buffer until normal heap pressure catches up. Respecting false turns the loop into a paced producer instead.

Figure 2 — Backpressure starts when a stdout destination cannot accept bytes as quickly as the program writes them. After write() returns false, the producer should pause and resume when the stream drains.
Terminal dimensions
TTY stdout exposes terminal size:
if (process.stdout.isTTY) {
console.log(process.stdout.columns); // e.g., 120
console.log(process.stdout.rows); // e.g., 40
}Those properties report width and height in character cells. They update when the terminal window changes size, and stdout emits resize:
if (process.stdout.isTTY) {
process.stdout.on('resize', () => {
console.log(`${process.stdout.columns}x${process.stdout.rows}`);
});
}Piped stdout and file-redirected stdout have columns and rows as undefined. Terminal UIs, progress bars, and table formatters usually fall back to 80 columns when there is no terminal size to read.
The resize event comes from libuv's TTY handle. On Unix, the terminal sends SIGWINCH when its dimensions change. libuv tracks that signal, asks the terminal for the current size with ioctl(fd, TIOCGWINSZ, &winsize), and reflects the result through the JavaScript stream.
ANSI cursor control
TTY stdout accepts ANSI escape sequences:
process.stdout.write('\x1b[2J'); // clear screen
process.stdout.write('\x1b[H'); // move cursor to top-left
process.stdout.write('\x1b[5;10H'); // move to row 5, col 10Cursor control is just bytes written to stdout. Packages such as ansi-escapes, chalk, and kleur wrap those byte sequences, but the final operation is still a write to fd 1.
Progress output often clears and redraws one line:
if (process.stdout.isTTY) {
process.stdout.write('\r'); // carriage return
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
process.stdout.write('Progress: 42%');
}clearLine() and cursorTo() exist on TTY streams and write ANSI sequences internally. Piped stdout lacks those methods, so code that draws terminal UI needs an isTTY branch.
That branch should usually change the output format, not only the helper methods. Interactive output can redraw one line. Piped output should usually append plain records. A progress bar that writes carriage returns into a log file creates unreadable bytes, while a CLI that switches to newline-delimited status records behaves better under shells, CI logs, and test snapshots.
process.stderr
process.stderr is the Writable stream for diagnostics. console.error(), console.warn(), console.trace(), and failed console.assert() calls write there. Regular informational console output, including console.log(), console.info(), console.debug(), console.dir(), console.dirxml(), console.table(), console.count(), console.timeLog(), and console.timeEnd(), writes to stdout. Some console methods only update console state: console.time() starts a timer, console.countReset() resets a counter, and console.groupEnd() closes a group without writing a line.
That method split is most important when a shell pipeline is involved. node app.js | grep pattern gives grep fd 1 only. Warnings, stack traces, progress lines, and debug output on fd 2 still appear in the terminal unless redirected.
console.log('data output'); // goes to pipe -> grep
console.error('debug info'); // goes to terminalThis is why diagnostics belong on stderr and program data belongs on stdout. Plenty of scripts start with console.log() for everything and later become hard to pipe because debug output contaminates the data stream. The small fix is consistent: console.error() for diagnostics, console.log() for data.
stderr is also the right stream for progress. A downloader can stream file bytes to stdout while progress goes to stderr. A formatter can write transformed JSON to stdout while parse warnings go to stderr. The caller can redirect them independently without asking the program for a custom flag.
The shell redirects fd 1 and fd 2 independently:
node app.js > output.txt 2> errors.txt
node app.js > output.txt 2>&1 # merge stderr into stdout
node app.js 2>/dev/null # discard errorsYour program receives only the final fd table. The shell does the setup before Node starts: it opens target files, calls dup2() to replace fd 1 or fd 2, then executes the Node binary. By the time JavaScript runs, process.stdout and process.stderr wrap whatever descriptors the shell left in place.
The 2>&1 form copies fd 1's current target into fd 2. Order is important because shells process redirections left to right. node app.js > out.txt 2>&1 sends both streams to out.txt. node app.js 2>&1 > out.txt sends stderr to the original stdout target and stdout to the file.
TTY detection
process.stdout.isTTY is true when stdout is connected to a terminal. For pipes and redirected files, the property usually reads as undefined, so CLI code should branch on truthiness instead of checking for === false.
if (process.stdout.isTTY) {
process.stdout.write('\x1b[31mred text\x1b[0m\n');
} else {
process.stdout.write('red text\n');
}Color-aware CLIs usually contain a branch close to that. ANSI escape bytes render as colors in a terminal. In a file or downstream program, those bytes appear literally, often as ^[[31m. isTTY tells the program whether fd 1 points at a terminal.
The same idea applies to all three streams:
| Stream | TTY case | Non-TTY case |
|---|---|---|
process.stdin.isTTY | input comes from an interactive terminal | input is piped |
process.stdout.isTTY | output goes to a terminal | output is piped or redirected |
process.stderr.isTTY | diagnostic output goes to a terminal | diagnostic output is redirected |
Each non-TTY case usually appears as undefined, not false. More importantly, each stream has its own TTY status. node app.js | cat makes stdout non-TTY while stderr can still be a TTY. node app.js 2>/dev/null changes stderr and leaves stdout alone.
That independence is important for color decisions. Many tools disable color on stdout when stdout is piped, but keep colored diagnostics on stderr if stderr still points at a terminal. Treating the whole process as "interactive" or "non-interactive" loses that detail.
Color detection
TTY streams also expose color capability checks:
if (process.stdout.isTTY) {
process.stdout.getColorDepth(); // 1, 4, 8, or 24
process.stdout.hasColors(256); // true/false
}getColorDepth() returns bits of color support. 1 means monochrome. 4 means 16 colors. 8 means 256 colors. 24 means true color. These methods live on TTY write streams. Piped stdout and file-backed stdout usually have no getColorDepth() or hasColors() method, so color checks need the isTTY branch first.
hasColors(count) checks whether the terminal supports at least that many colors. You can pass an environment object as the second argument, such as hasColors(256, myEnvObject), which makes tests easier because they can simulate TERM, COLORTERM, NO_COLOR, NODE_DISABLE_COLORS, or FORCE_COLOR. That object affects the TTY helper call. It does not add TTY helper methods to a non-TTY stream.
The helper checks environment conventions including COLORTERM, TERM, NO_COLOR, NODE_DISABLE_COLORS, and FORCE_COLOR. NO_COLOR and NODE_DISABLE_COLORS reduce Node's color depth decision to monochrome. FORCE_COLOR raises the requested color level for TTY color detection. Some libraries also treat FORCE_COLOR as permission to emit ANSI bytes for non-TTY output, but process.stdout.hasColors() still exists only when stdout is a TTY write stream.
if (process.env.NO_COLOR || process.env.NODE_DISABLE_COLORS) {
// user explicitly wants no color
} else if (process.stdout.isTTY && process.stdout.hasColors(256)) {
// use 256-color output
} else if (process.stdout.isTTY) {
// basic 16-color output
}Libraries such as chalk, kleur, and colorette perform this detection internally. Application code usually calls the library and lets it decide whether to emit ANSI sequences.
Blocking and Nonblocking Writes
stdin, stdout, and stderr have connection-dependent blocking behavior, and the matrix changes across Linux, macOS, and Windows.
Start with terminals. On Linux and macOS, writes to process.stdout and process.stderr are synchronous when the stream is connected to a terminal. The write() call blocks the event loop until the kernel accepts the bytes for the terminal driver. On Windows, TTY writes are asynchronous because libuv routes them through the Windows console path.
Pipes flip part of the matrix. On POSIX platforms, pipe writes are asynchronous. Data enters a Node/libuv write path, then the kernel pipe buffer. A slow consumer fills that buffer, and backpressure moves back into the Writable stream. The kernel pipe buffer is commonly 64KB on Linux and 16KB on macOS, though kernel versions and settings can change the exact number. On Windows, pipe-backed standard stream writes are synchronous.
Files are simpler. Redirect stdout to a file with node script.js > output.txt, and writes are synchronous on supported platforms. The syscall returns after the kernel accepts the bytes into the file path, although the kernel may flush them to physical storage later.
Put together, TTY writes are synchronous on POSIX and asynchronous on Windows. Pipe writes are asynchronous on POSIX and synchronous on Windows. File writes are synchronous across supported platforms.
The callback passed to .write() follows the same surface API across those cases, but its timing follows the backing handle. For a pipe, the callback means libuv completed the async write request. For a Unix TTY, it can run after the blocking syscall returns in the same turn. Same API, different timing.
The behavior comes from libuv's handle choice. When fd 1 or fd 2 is a TTY, libuv uses uv_tty_t. On Unix, that path writes directly with blocking write(2) calls. Terminal output is usually small, so the blocking path avoids queue setup for writes that normally complete during the syscall. When the fd is a pipe, libuv uses uv_pipe_t, which participates in the event loop and owns an async write queue. Pipe output can stall for longer because the reader controls how fast the kernel buffer drains. Regular files use synchronous writes because thread-pool file I/O could complete out of order.
That last bit affects output order. Two stdout writes should preserve insertion order. If Node sent redirected file writes through the libuv thread pool, worker scheduling could let write 2 finish before write 1. Synchronous writes preserve textual order at the cost of blocking the main thread when the file target is slow.
Slow file targets are real. Redirecting to a network mount, a nearly full disk, or a busy filesystem can make a stdout write block long enough to show up in latency. Most CLI tools accept that cost. A server process that logs heavily to a slow redirected stdout should measure it instead of assuming logging is free.
process.exit() and buffered output
process.stdout.write('results\n');
process.exit(0);TTY stdout on Unix usually reaches the terminal before process.exit() because the write blocks until the kernel accepts the bytes. Pipe-backed stdout on POSIX can lose the line. process.stdout.write() queues the chunk and returns. process.exit() terminates the process before the queued write completes.
This is a common CLI failure mode. The program appears correct in a terminal, then loses the final line under | tee or a log collector. The bug sits in the connection type, not in the string being written.
Use the write callback when explicit exit is required:
process.stdout.write('results\n', () => {
process.exit(0);
});For console.log(), the cleaner answer is usually process.exitCode:
process.exitCode = 0;
console.log('results');Set the exit code, stop scheduling new work, and let the event loop drain so pending writes can finish. drain helps only after a write() returns false; a small queued pipe write can still have no drain event to wait for. The write callback is the precise hook for one chunk.
There is also a console-specific wrinkle. console.log() has no per-call completion callback. It formats and writes, then returns. If the process needs to exit after a final message, process.stdout.write(message, callback) gives you a concrete completion point. console.log() is fine for normal natural exit. It is clumsy for explicit shutdown sequencing.
stderr follows the same connection-type matrix, with a practical convention layered on top. Crash diagnostics usually go to stderr because stderr is often still attached to a TTY, and Unix TTY writes block. Piped stderr can still lose buffered data if process.exit() runs before the write completes.
Fatal-path code should minimize asynchronous assumptions. Write the diagnostic, prefer process.exitCode when normal shutdown is still possible, and reserve process.exit() for cases where immediate termination is the actual requirement.
The console object
The global console object is a Console instance wired to process.stdout and process.stderr.
import { Console } from 'node:console';
const logger = new Console({
stdout: process.stdout,
stderr: process.stderr
});
logger.log('ready');The default global console is configured along those lines. Each method formats arguments, picks stdout or stderr, and writes to that stream. By default, Console ignores write errors from the destination streams; pass ignoreErrors: false if logging failures should surface. The same class can write to files instead:
import { Console } from 'node:console';
import { createWriteStream } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const log = new Console({
stdout: createWriteStream(join(tmpdir(), 'app.log')),
stderr: createWriteStream(join(tmpdir(), 'app.err')),
ignoreErrors: false
});
log.log('this goes to app.log');The method split follows the output being emitted. Regular informational output goes to stdout: console.log(), console.info(), console.debug(), console.dir(), console.dirxml(), console.table(), console.count(), console.timeLog(), and console.timeEnd(). console.group() writes its optional label to stdout, then changes indentation state. console.error(), console.warn(), console.trace(), failed console.assert() calls, and console warnings such as a missing timer label write to stderr. console.countReset(), console.time(), and console.groupEnd() can change console state without producing output.
Several of those methods share implementation. console.log() and console.info() go through the same path, as do console.error() and console.warn(). They call util.format() and write the result to the selected stream.
console.table([
{ name: 'alice', score: 95 },
{ name: 'bob', score: 87 }
]);console.table() prints a formatted text table to stdout. Current Node versions use box-drawing characters, inspect object values, extract column names from keys, and pad cells for alignment. A second argument selects columns: console.table(data, ['name']). The output is meant for people. Pipe-friendly tools should emit JSON, NDJSON, or CSV.
Because the table goes to stdout, shell redirection captures it. That is fine for human-readable reports. It is a poor default for APIs between programs because spacing, truncation, and object inspection rules are presentation details.
console.time('work');
await new Promise((resolve) => setTimeout(resolve, 10));
console.timeEnd('work'); // work: 10.123msconsole.time() starts a high-resolution timer under the label you pass. console.timeEnd() stops it and writes elapsed milliseconds to stdout. console.timeLog() writes elapsed time without stopping the timer. Multiple labels can run at once. A missing label warning goes to stderr.
Internally, the timing path uses a high-resolution clock, so the value can include fractional milliseconds. The output still goes through the same stdout path as console.log(). In a pipeline, timing lines become part of the data stream unless you use a custom Console that sends timing output to stderr.
console.trace('checkpoint');console.trace() writes Trace: checkpoint plus the current stack trace to stderr and keeps the program running. It uses the same file and line formatting you see in Error stacks.
That makes it useful for temporary diagnostics in a CLI pipeline. The transformed data keeps moving through stdout, while stack information lands on stderr where the next program in the pipe will not parse it as data.
Piping patterns
stdin and stdout make Node fit naturally into Unix-style pipelines.
process.stdin.pipe(process.stdout);That one line reads fd 0 and writes fd 1. stdin is a Readable, stdout is a Writable, and .pipe() connects them with backpressure handling.
A line filter adds only enough structure to process records one at a time:
import { createInterface } from 'node:readline';
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
process.stdout.write(line.toUpperCase() + '\n');
}Run it as cat file.txt | node upper.js | head -5. stdin comes from cat, stdout goes to head, and stderr remains available for diagnostics.
The same shape works for JSONL, with valid output and bad input kept on different streams:
import { createInterface } from 'node:readline';
const rl = createInterface({ input: process.stdin });
for await (const line of rl) {
try {
const obj = JSON.parse(line);
if (obj.level === 'error') process.stdout.write(`${line}\n`);
} catch {
process.stderr.write(`invalid JSON: ${line}\n`);
}
}The readline interface creates a line iterator over fd 0, then the loop filters records. Valid matching records go to stdout. Parse failures go to stderr. With node filter.js < logs.jsonl > errors.jsonl 2> parse-failures.txt, the caller gets clean data in one file and diagnostics in another.
The try/catch keeps the pipeline alive after malformed input: report the bad line, skip it, and keep reading.
There is still a stream detail inside that loop: process.stdout.write() can return false. For small log filters, ignoring it may be acceptable. For high-volume filters, pause the input path or use a Transform plus pipeline() so backpressure travels through the whole chain.
When stdin is piped, it ends when the upstream writer closes:
let total = 0;
process.stdin.on('data', (chunk) => {
total += chunk.length;
});
process.stdin.on('end', () => {
console.log(`Read ${total} bytes`);
});TTY stdin ends when the user sends EOF. Code can handle both cases through the same end event.
Pipeline shutdown has direction. In node producer.js | node consumer.js, producer exit closes the pipe and consumer stdin emits end. Consumer exit closes the read side. A generic Unix producer may receive SIGPIPE when it writes after that point; a Node producer typically sees an EPIPE error on process.stdout because Node ignores SIGPIPE during startup. Those two directions surface differently.
That asymmetry is normal for Unix pipes. Upstream completion is data EOF. Downstream completion is a broken output target. Good CLI programs treat the first as completion and the second as a clean early stop when the downstream command intentionally quits.
For stream-shaped transformations, pipeline() gives you error propagation and cleanup:
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
const upper = new Transform({
decodeStrings: false,
transform(chunk, enc, cb) {
cb(null, chunk.toUpperCase());
}
});
await pipeline(process.stdin.setEncoding('utf8'), upper, process.stdout);The line-by-line version is smaller for text filters. setEncoding('utf8') keeps multibyte characters intact across chunk breaks before the Transform sees text. pipeline() is the better fit when the transformation is naturally stream-shaped and you want backpressure end to end.
Bootstrapping stdin, stdout, and stderr
In Node v24, before user code runs, the runtime installs lazy getters for the standard streams on process. The main-thread implementation currently lives in lib/internal/bootstrap/switches/is_main_thread.js; worker threads use a separate path for redirected stdio. These file names are implementation details, not API contracts.
Access triggers creation. In Node v24, the first process.stdout read calls an internal getStdout() function, which calls createWritableStdioStream(1). From there, Node calls guessHandleType(fd) from internal/util. That helper wraps internalBinding('util').guessHandleType(fd), whose C++ implementation calls libuv's uv_guess_handle(fd).
uv_guess_handle() runs fstat() on the fd and checks terminal status with isatty(fd) on Unix. Node maps the native result into internal names such as TTY, PIPE, TCP, FILE, and UNKNOWN. That classification decides which JavaScript stream object gets created.
The writable path is short but meaningful. getStdout() handles caching so repeated process.stdout reads return the same object. createWritableStdioStream(1) classifies fd 1. The classification chooses the constructor and write policy. After that, the resulting stream is stored on the process object and reused. process.stderr follows the same path with fd 2. stdin follows a sibling readable path with fd 0.
The lazy part is important for lifetime. A script that writes to stdout and never touches stdin leaves the stdin handle uncreated, which avoids creating a libuv handle that would need shutdown management.
When the classification is TTY, the descriptor points at a terminal. For fd 1 and fd 2, Node exposes the handle through tty.WriteStream, which extends net.Socket. The write-stream path feeds columns, rows, cursor helpers such as clearLine() and cursorTo(), and color helpers such as getColorDepth() and hasColors(). It does not provide setRawMode().
For fd 0, TTY stdin is exposed through tty.ReadStream. That read-stream path provides setRawMode(), ref(), and unref(), and it coordinates terminal mode restoration for normal shutdown. Window size still comes from the terminal through calls such as ioctl(fd, TIOCGWINSZ, &winsize) on Unix, but raw-mode control belongs to the read side.
The socket-shaped JavaScript API comes from Node's stream stack. TTY stdin needs readable stream behavior, and TTY stdout/stderr need writable stream behavior, but both reuse net.Socket-like wrappers around native handles. The fd still points at a terminal device, so socket methods that require a peer address have little useful information to return.
When the classification is PIPE or TCP, fd 1 or fd 2 points at a pipe, socket, or similar stream handle. Node exposes it as a net.Socket in writable mode. Writes enter libuv's write queue as uv_write_t requests. libuv registers interest in fd writability through epoll on Linux or kqueue on macOS. When the kernel says the pipe can accept bytes, libuv writes. If the kernel pipe buffer is full, the request stays queued and Node's stream buffer eventually reports backpressure through write() === false.
That path is why pipe-backed stdout can keep the process alive after your synchronous JavaScript has finished. Pending uv_write_t requests are active work. The event loop keeps running until those requests complete or fail. Calling process.exit() bypasses that drainage; natural exit lets it happen.
When the classification is FILE, the fd points at a regular file. Node creates an internal SyncWriteStream for stdout or stderr, and an fs.ReadStream for stdin. Standard stream writes to files use synchronous file writes internally. The thread pool path could reorder concurrent writes because worker completion order is scheduler-dependent. Sync writes preserve textual order.
Regular files also explain why redirected stdout can be slower than terminal output. On a local SSD, synchronous writes into the kernel page cache can be cheap. On a remote filesystem, the same call can block longer. The stream API hides that difference, but latency still lands on the JavaScript thread.
UNKNOWN is the fallback. For stdout and stderr, Node creates a dummy Writable that accepts chunks and completes the write callback. That path is rare for normal shells, but it is relevant for embedded runtimes, unusual supervisors, and non-console Windows applications. The fallback keeps the standard stream property present even when libuv cannot classify the descriptor.
stdin uses the same detection path with Readable stream creation. TTY stdin gets a TTY-capable socket wrapper. Pipe stdin gets a pipe-backed socket. File stdin, such as node script.js < input.txt, gets an fs.ReadStream.
The file case for stdin is common in batch tools. node parse.js < input.ndjson gives your program an fs.ReadStream over fd 0. The JavaScript code can still consume process.stdin with for await, data, or pipe(). The source changed from a terminal to a file descriptor backed by a regular file; the consumer code still sees a Readable.
TTY stdin has one extra path: terminal-generated Ctrl+C. In cooked mode, the terminal driver turns Ctrl+C into SIGINT. In raw mode, the byte 0x03 reaches JavaScript. Switching between those modes requires saving and restoring terminal attributes with tcsetattr(), and libuv's TTY code coordinates that state with Node's signal behavior.
Worker threads receive redirected standard streams. process.stdout and process.stderr in a worker send data to the parent thread over an internal channel by default, and the parent writes to its own fd 1 or fd 2 unless the Worker was created with stdout: true or stderr: true. That makes worker stdout and stderr asynchronous. Worker stdin is a ReadableWorkerStdio stream; by default it receives no data, and new Worker(..., { stdin: true }) gives the parent a writable worker.stdin whose chunks appear on the worker's process.stdin.
That worker behavior can affect test output. A worker's console.log() call travels through the parent before hitting the real stdout. Ordering against parent-thread logs depends on message delivery, stream state, and the parent write path. For exact ordering, send structured messages to the parent and let one thread own the final output.
Operational checklist
- Keep machine-readable data on stdout and diagnostics on stderr.
- Check each stream's own
isTTYvalue before emitting colors or cursor control. - Respect
write()backpressure for large output, or usepipeline(). - Prefer
process.exitCodewhen normal shutdown can flush pending writes. - Use a write callback when a final chunk must land before explicit exit.
- Handle
EPIPEin producer-style CLIs that may be piped intohead,grep, or another early-exiting consumer.
Edge cases and gotchas
TTY-backed and pipe-backed stdout are net.Socket instances:
import net from 'node:net';
console.log(process.stdout instanceof net.Socket);
// true for TTYs and pipes, false for regular filesSome inherited socket methods make little sense on a terminal fd or pipe fd. Use process.stdout.isTTY for terminal-specific behavior. The useful detail is error behavior: stdout can emit error if the underlying fd write fails.
Broken pipes are the common failure case. Run node app.js | head -1, and head exits after one line. The next stdout write in your program hits a closed read side. Node ignores SIGPIPE at startup and turns the failed write into an EPIPE error on process.stdout.
process.stdout.on('error', (err) => {
if (err.code === 'EPIPE') {
process.exit(0);
}
});Any program intended for pipelines should handle EPIPE. The handler turns early consumer exit into normal completion instead of an uncaught stream error.
head is the usual repro because it exits intentionally after receiving enough lines. The producer did nothing wrong; the consumer closed the pipe on purpose. Treating EPIPE as success keeps shell pipelines quiet.
Mixed sync and async writes can surprise you:
process.stdout.write('A');
setTimeout(() => process.stdout.write('B'), 0);
process.stdout.write('C');On a TTY, A and C write synchronously, then the timer writes B, so output is ACB. On a pipe, A and C usually queue before the timer phase, so the output is still ACB. With enough data and backpressure, timing can become more visible, but JavaScript call order still determines queue order until you yield to the event loop.
console.log() can block because it writes to stdout, and stdout can be synchronous. Heavy terminal logging can dominate a benchmark. Pipe to /dev/null or redirect to a file when you want to measure application work without terminal write latency.
There is a second benchmarking trap: redirecting stdout to a regular file can also be synchronous. /dev/null usually removes most cost because the kernel discards the bytes quickly. A real file on a slow device measures the filesystem too.
isTTY gives one bit of classification. Pipes and redirected files both read as undefined. If code truly needs to distinguish them, fs.fstatSync(1) can inspect fd 1 and report whether it points at a pipe, character device, or regular file. Most CLIs only need the TTY branch.
That deeper detection belongs in tools with different file and pipe behavior. A program might choose seek-friendly output when fd 1 is a regular file, but stream records when fd 1 is a pipe. Most programs skip that branch because stdout is a destination API, even when the descriptor points at a file.
Process exit is the last trap. process.exit() runs exit handlers synchronously and terminates. Pending async writes stay pending forever. process.exitCode = N is the safer control point:
process.exitCode = 1;
console.error('something went wrong');
// let the event loop drainSet the code, stop creating new work, and let the loop empty. That gives pipe-backed stdout and stderr a chance to flush before the process disappears.
Standard streams look small from JavaScript, but they carry the process scope with them. The shell decides the descriptors, Node wraps them lazily, and libuv picks the handle type. Your code sees a stream, but the stream's behavior comes from that whole setup.