Node.js — process.exit() Truncates Piped stdout
Explain why calling process.exit() mid-write truncates output when stdout is a pipe, and replace it with a drain-aware shutdown that flushes every buffer.
Symptoms
- A script piped into another command produces truncated JSON or cut-off lines, ending mid-object
- The same script prints the full output when run directly in a terminal
- process.exit(0) is called immediately after a large console.log or process.stdout.write
Possible causes
- process.exit() terminates synchronously while stdout writes to a pipe are still queued
- stdout to a terminal is line-buffered and effectively synchronous, hiding the race during development
- A catch block calls process.exit before the async write callback runs
- A stream pipeline is left un-awaited before exit is invoked
Diagnosis steps
Reproduce with a pipe that slows the drain
node gen.js | cat > out.json && tail -c 40 out.jsonInterpretation
A file ending mid-object or mid-line confirms truncation; comparing with direct terminal output isolates the pipe-only nature.
Locate every process.exit call
grep -Rn "process.exit" src/ scripts/Interpretation
Each call after a write is a candidate for cutting the queue before the OS flushes it.
Confirm the queue is non-empty at exit
process.stdout.write(data); console.error('queued', process.stdout.writableLength)Interpretation
A non-zero writableLength at the moment of exit is the direct evidence that buffered bytes were dropped.
Root cause
process.exit() terminates the process immediately and does not wait for pending asynchronous writes to drain. When stdout is a pipe, writes are asynchronous and buffered, so bytes still in the writable queue at exit time never reach the reader, truncating the output.
Fix
Remove process.exit() from normal completion paths and let the event loop end naturally after streams end. When an explicit exit code is required, await stdout draining or stream completion, and reserve direct process.exit for synchronous fatal errors where output integrity is moot.
Risk note
An explicit exit with an unset code masks failures. Always pass the captured status to the final exit call.
Verify the fix
Pipe the fixed script into cat and compare byte counts against direct execution; assert identical sizes across repeated runs and a mid-stream SIGTERM graceful path.
Symptom walkthrough
Truncation that only exists behind a pipe is the fingerprint:
node gen.js | cat > out.json
tail -c 32 out.json
# {"rows":[{"id":997,"name":"r… <- ends mid-object
In a terminal the same command prints everything, because a tty writer is synchronous enough to hide the race. The mechanism is visible with one instrumentation line placed right before the exit:
process.stdout.write(payload);
console.error('queued bytes:', process.stdout.writableLength);
process.exit(0); // kills the queue above
A non-zero queued count at exit is the smoking gun.
Fix in practice
The default fix is to delete process.exit and let the loop drain:
async function main() {
process.stdout.write(payload);
// no exit call: loop ends when stdout finishes
}
main().catch((err) => {
console.error(err);
process.exitCode = 1; // recorded, applied after drain
});
When the exit must be explicit, wait for the writer first:
await new Promise((resolve) => process.stdout.write(payload, resolve));
process.exit(exitCode);
For pipelines built on stream helpers, await stream.pipeline completion before exiting, and audit catch blocks for exit calls that race pending writes.
Verification
Compare byte counts across the two execution modes: direct terminal output and piped-to-cat output must match exactly, repeatedly. Add a stress case writing a payload several megabytes larger than the pipe buffer so kernel-level backpressure is exercised. Assert the exit code is still correct for the failure path by forcing an error and checking $? equals 1, proving the drain-aware shutdown preserved error semantics.