~/blog

process.exit() truncates your output when stdout is a pipe

published

#node#cli#stdout

TL;DR

process.exit() terminates the process immediately, including while writes to process.stdout are still queued. Whether those writes were queued at all depends on the platform and on what stdout is connected to, so the same script prints everything in your terminal and drops lines the moment it is piped or redirected in CI. Set process.exitCode and let the process end on its own.

The problem

Here is a CLI that prints a report and exits:

// report.mjs
for (let i = 0; i < 20000; i++) console.log(`line ${i}`);
process.exit(0);

Run it in a terminal on Linux or macOS and you see all 20000 lines. Now count them:

node report.mjs | wc -l

You get fewer than 20000. No error, no warning, exit code 0. The same thing happens with > out.txt on some setups, in Docker, and in every CI runner — anywhere stdout is not your terminal.

The failure mode is the worst kind: the exit code says success, and the missing data is at the end, which is exactly where a summary line or a JSON result usually lives.

Why it happens

Two separate facts combine.

First, process.exit() does not wait. From the Node documentation:

Calling process.exit() will force the process to exit as quickly as possible even if there are still asynchronous operations pending that have not yet completed fully, including I/O operations to process.stdout and process.stderr.

The docs even ship the anti-pattern:

import { exit } from 'node:process';
// This is an example of what *not* to do:
if (someConditionNotMet()) {
  printUsageToStdout();
  exit(1);
}

with the explanation that “writes to process.stdout in Node.js are sometimes asynchronous and may occur over multiple ticks of the Node.js event loop”.

Second, “sometimes” is a matrix. Whether a write to stdout is synchronous depends on the platform and the destination:

stdout is connected toPOSIX (Linux, macOS)Windows
Filessynchronoussynchronous
TTYs (terminals)synchronousasynchronous
Pipes and socketsasynchronoussynchronous

Read that table twice, because the two rows that matter are inverted. On Linux and macOS your terminal is the safe case and a pipe is the dangerous one — so the bug appears the instant you pipe to head, jq, tee, or a CI log collector. On Windows it is the other way around: the console is the case that can truncate, and piping is safe.

That is why this reproduces for one developer and not another, on the same code, at the same commit.

Note also that a short write may survive by accident. A single line usually fits in the pipe buffer and lands in one shot; twenty thousand lines do not. So the bug scales in with output size, which makes it look like an unrelated regression when a report grows.

What to do

Set the exit code and stop scheduling work. The process exits by itself once the event loop is empty, and the pending writes flush first:

import process from 'node:process';
// How to properly set the exit code while letting
// the process exit gracefully.
if (someConditionNotMet()) {
  printUsageToStdout();
  process.exitCode = 1;
}

Applied to the report above:

// report.mjs
for (let i = 0; i < 20000; i++) console.log(`line ${i}`);
process.exitCode = 0; // or just omit it; 0 is the default

node report.mjs | wc -l now prints 20000 on every platform.

If you genuinely must terminate at a specific point, wait for the write to be acknowledged. process.stdout.write takes a completion callback; console.log does not:

process.stdout.write(finalReport, () => process.exit(1));

And when you need a hard, unconditional write — inside an uncaughtException handler, say, where the loop may never turn again — write to the file descriptor synchronously:

import { writeSync } from 'node:fs';
writeSync(1, 'fatal: config not found\n'); // 1 = stdout, 2 = stderr
process.exit(1);

Caveats

References