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.
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 -20Interpretation
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.