Node.js

Node.js — Process exits before asynchronous writes flush

Written and reviewed by Sahil Srivastav

ShutdownOutput integrityAsync cleanup

What this error actually means

There is no single Node error message for output lost during exit. The process can return an apparently successful exit status while buffered output, queued telemetry or an unfinished write is abandoned. process.exit forces termination rather than waiting for arbitrary asynchronous work to finish.

Natural exit follows event-loop liveness, not the existence of JavaScript promises. A pending promise with no referenced handle or active operation does not keep Node alive by itself. Unreferenced timers and sockets are explicitly allowed not to hold the process open, so work scheduled through them needs a deliberate owner if completion matters.

Shutdown has several different boundaries: stop accepting new work, settle current operations, close resources, flush output and choose an exit status. Calling exit in the middle collapses those boundaries. A timeout can bound shutdown, but reaching that timeout should be recorded as incomplete termination rather than silently reported as successful completion.

Causes, most common first

  1. 1process.exit is called immediately after scheduling output. stdout and stderr write behaviour varies with the destination and platform. A console.log followed by forced exit is therefore not a portable flush protocol. The same issue applies to file streams or library buffers whose completion has not been awaited.
  2. 2Cleanup is placed in the exit event. The exit event is a synchronous final notification. Scheduling asynchronous work there does not create a dependable opportunity to finish it. A signal handler can begin an orderly asynchronous shutdown while the event loop is still running; exit is too late for that design.
  3. 3Important work uses only unreferenced handles. A delayed flush scheduled with an unref timer can be skipped when no other work keeps the loop alive. A pending promise waiting on that timer does not reverse the unref decision. The optimisation is valid only when losing the scheduled work is acceptable.
  4. 4The supervisor’s deadline is shorter than the drain. An orchestrator can terminate the process before database transactions, active requests or SDK flushes finish. The application may also receive multiple signals. A shutdown path that starts fresh cleanup on every signal can race its own resource closures.

When you see it

  • The final log lines or generated output disappear when stdout is piped
  • A command works interactively but writes truncated output in CI
  • Deployment shutdown drops the last batch of events or leaves partial files
  • An async exit handler logs its first line but never finishes awaited cleanup

How to diagnose it

Step 1

Locate every forced exit and late cleanup hook

Review library wrappers and CLI helpers as well as the application entry point. Track whether exit occurs after awaited completion or immediately after starting work. Do not replace all exits mechanically; a forced failure may be intentional but must carry an accurate exit status.

rg -n "process\.exit|process\.exitCode|beforeExit|[\"\x27]exit[\"\x27]|\.unref\(" src

Step 2

Reproduce with output redirected

Run the command through the same destination as production: a pipe, file or log collector. Count expected records and verify the final record exists. Terminal success does not prove pipe behaviour because stdout synchronisation differs by destination and operating system.

Step 3

Trace the shutdown timeline

Record signal arrival, stop-admission completion, last in-flight request, resource closure and final flush. Compare total duration with the supervisor’s grace period. If the process receives a forced kill, application hooks cannot finish after that point.

Step 4

Verify the actual completion primitive

A stream write returning true only indicates buffer pressure, not final completion of all future writes. Use end plus finished for a writable stream, the library’s documented flush/close promise for SDKs, and pool.end for the database pool after borrowers return.

The fix

For a CLI, await its main operation and cleanup, set process.exitCode on failure, then allow natural exit once resources close. This permits referenced I/O to complete. A resolved main promise does not close an accidentally live server or timer, so resource ownership still needs to be explicit.

Handle SIGTERM with one idempotent shutdown function. Mark the instance unavailable, stop new admissions, drain current requests and then close dependent pools and exporters. Closing the database pool before requests finish can convert an orderly shutdown into application errors.

Await each output system’s own completion contract. For a writable file stream, end input and await finished; for a queue producer, await its flush or close method. Do not assume a generic sleep proves delivery or durability.

Set a bounded overall shutdown deadline shorter than the supervisor’s hard limit. On expiry, record which operations remain and exit unsuccessfully if completion was required. Make durable business work recoverable through queues or transaction records instead of relying solely on a graceful exit window.

import { finished } from "node:stream/promises";

async function main() {
  const output = openOutputStream();
  try {
    await writeReport(output); // Honour backpressure inside this function.
    output.end();
    await finished(output);
  } catch (error) {
    output.destroy();
    throw error;
  }
}

try {
  await main();
} catch (error) {
  console.error(error);
  process.exitCode = 1;
}
// Resources are closed; let referenced output operations finish naturally.

How to stop it coming back

  • Test shutdown during active requests and while output is backpressured
  • Use one lifecycle owner for servers, pools, producers and buffered exporters
  • Assert exit status and output completeness in CLI integration tests
  • Align application drain budgets with supervisor termination grace periods

Practise production debugging in a real repository

Reading about a failure and reproducing one are different skills. Gronex ships broken backend repositories with failing test suites that encode the real invariant, so you debug from evidence instead of memorising symptoms.

FAQ

Can an async process.on("exit") callback flush logs?

No dependable asynchronous work can be scheduled at that final event. Flush earlier during controlled shutdown. Keep exit listeners synchronous and limited to last-chance observations that do not require the loop to continue.

Does an unresolved promise keep Node running?

Not by itself. Active operations and referenced handles determine whether there is work keeping the loop alive. A promise waiting on an unreferenced timer can remain unresolved when the process exits.

Is waiting one second before exit sufficient?

It only makes loss less likely under one workload. Await an explicit completion signal and enforce a measured deadline. A slow disk, blocked pipe or unavailable collector can outlast any arbitrary delay.

Related

Other errors engineers hit next to this one

Full error and symptom index →