← Debugging Guides

阅读中文版本

Node.js — The Process Never Exits: Something Holds the Loop

Find why a Node.js script finishes its work but the process stays alive, enumerate active handles and ref state, and unref or close the right resource.

intermediatedebuggingnodejsevent-looplifecycle

Symptoms

  • The script prints its final output but the process hangs until killed or the CI step times out
  • docker run or a CI job wrapper reports the step as stuck after the real work completed
  • No error is logged; the process simply does not return to the shell

Possible causes

  • An open handle keeps the loop alive: a database pool, a Redis client, a keep-alive HTTP agent, or a message consumer
  • A setInterval timer was created and never cleared or unref'd
  • A child process or worker thread still runs in the background
  • A socket with SO_KEEPALIVE remains half-closed after a partial protocol exchange

Diagnosis steps

Enumerate what the loop is waiting for

process._getActiveHandles(); process._getActiveRequests();

Interpretation

Called right after your last output, the handle list names the exact resource holding the process open — typically a pool or a timer.

Use the structured alternative

node --expose-internals -e "process.on('exit', () => console.log(process._getActiveHandles()))" # or: node -e "const p = require('node:process'); setInterval(()=>{},1000).unref(); console.log(require('util').inspect(process._getActiveHandles(), {depth:1}))"

Interpretation

Handle types map to owners: Timers to setInterval, Socket to pools and agents, ChildProcess to spawn leftovers.

Test the suspect resource directly

grep -Rn "setInterval\|createPool\|createClient\|subscribe" src/ | head -20

Interpretation

Matching creation sites without matching close sites identify the lifecycle gap to fix.

Root cause

Node exits when the event loop has nothing left to do. A still-referenced handle — a pooled connection, an un-cleared interval, a running child process — counts as something to do, so the process stays alive long after the script work itself finished.

Fix

Close resources at the end of main: end pools and clients, clear intervals, terminate workers, and await their completion. For resources that must exist but should not govern lifetime, call unref so they inform the loop without keeping it alive.

Risk note

unref on a handle doing real work lets the process exit mid-operation and truncates writes. Only unref background liveness signals, never work-in-flight.

Verify the fix

Run the script and assert it returns to the shell within a fixed bound; log process exitCode and confirm the exit handler sees an empty active handle list.

Symptom walkthrough

The script completes its work, prints the last line, and then nothing — the shell never returns. Node exits only when the loop drains, so ask the loop what it still holds:

import { inspect } from 'node:util';

process.on('exit', () => {});
setTimeout(() => {
  console.log('work done');
  console.log(inspect(process._getActiveHandles(), { depth: 1 }));
  // [Timeout, Socket, Socket, ChildProcess]
}, 100);

Each handle type is a confession: a Timeout means an uncleared interval, Sockets usually belong to a database pool or keep-alive agent, a ChildProcess means an un-awaited spawn. Map handles to their creation sites and the lifecycle gap becomes concrete.

Fix in practice

End the owners explicitly at the end of main, in dependency order:

async function main() {
  await runJob();
  await consumer.stop();
  await pool.end();       // closes pooled sockets
  await redis.quit();
  clearInterval(metricsTimer);
  await worker.terminate();
}

main()
  .then(() => process.exitCode = 0)
  .catch((err) => { console.error(err); process.exitCode = 1; });

For handles that must exist but should not decide the lifetime — a heartbeat interval, a metrics socket — invert their relationship to the loop:

const heartbeat = setInterval(ping, 30_000);
heartbeat.unref(); // loop ignores it when otherwise empty

Never unref a handle whose pending operation carries real work: an unref’d socket mid-write lets the process exit and truncates the payload. The rule is liveness signals get unref, work-in-flight gets awaited.

Verification

<arg_value>Time the return empirically: run the script with a wall-clock guard such as timeout 10 node job.js and assert the command completes well inside it. Log the handle list in the exit path and require it to be empty (or contain only documented unref’d liveness handles). Finally, assert exitCode propagation: a failing run must exit non-zero, because a hang fix that swallows error codes converts a visible failure into a silent one.