Node.js — Worker Thread Exits with Code 13 (Unfinished Work)
Decode worker_threads exit code 13, distinguish it from crashes and uncaught exceptions, and shut workers down cleanly with explicit completion signaling.
Symptoms
- Worker terminated with exit code: 13 appears in parent logs without any error stack from the worker
- The job the worker was supposed to finish produces no result and no failure record
- The exit happens at shutdown or when the pool scales down, not during normal operation
Possible causes
- The parent called worker.terminate() (directly or via a pool destroy) while tasks were still queued inside the worker
- The worker event loop drained because all handles were unref'd or closed, so it exited before flushing pending work
- A pool library reaps idle-looking workers that are actually waiting on a ref-counted handle
- Graceful shutdown logic does not await in-flight task completion before terminating the pool
Diagnosis steps
Confirm the exit class from the parent side
worker.on('exit', (code) => log({ code, hasErr: false }));Interpretation
Code 13 with no error event means unfinished work at termination; codes 1 or 7 accompany an uncaught exception, which is a different failure class.
Trace the termination source
grep -R "terminate(\|destroy(" src/ | grep -v testInterpretation
Every terminate call site is a candidate for cutting workers off before their queue empties.
Instrument in-flight task counts
worker.postMessage({ type: 'stats' }) # worker replies with queue depth and active handlesInterpretation
A nonzero queue depth at terminate time proves tasks were dropped rather than completed.
Root cause
Exit code 13 is Node's sentinel for unfinished work: the worker process ended while tasks were still pending, either because the parent terminated it early or because the worker event loop drained with handles that kept work queued. The parent receives the code but no error, because from the runtime perspective nothing failed.
Fix
Make completion explicit. Track task acknowledgments in the parent, await them during shutdown before terminating, let workers signal when their queue is empty instead of relying on event loop drain, and set up an exit handler that re-enqueues tasks the worker dropped.
Risk note
Retrying every dropped task blindly can duplicate side effects. Persist task state before execution and make retries idempotent.
Verify the fix
Run a shutdown under load: assert zero exit code 13, all dispatched tasks acknowledged or re-enqueued, and no task result arriving after its re-enqueued twin.
Symptom walkthrough
Exit code 13 arrives without an error object, which is the first hint that nothing crashed:
worker 7 terminated with exit code: 13
Cross-reference the timing. If the exit lines up with a scale-down or process shutdown, the parent cut the worker loose. If it happens spontaneously, the worker event loop drained with pending work queued behind unref’d handles or closed channels. Ask the worker directly before it dies:
setInterval(() => {
parentPort.postMessage({ type: 'stats', queued: taskQueue.length });
}, 1000);
A last stats message showing queued > 0 followed by exit 13 is the receipt for dropped work.
Fix in practice
Two changes close the gap. In the worker, keep the loop alive and signal readiness to drain explicitly:
// worker.js
async function processQueue() {
while (taskQueue.length) {
await run(taskQueue.shift());
parentPort.postMessage({ type: 'ack', id: lastId });
}
parentPort.postMessage({ type: 'drained' });
}
In the parent, treat shutdown as a protocol rather than a terminate call:
async function stopWorker(worker) {
const drained = once(worker, 'message', (m) => m.type === 'drained');
worker.postMessage({ type: 'shutdown' }); // stop accepting, finish queue
await Promise.race([drained, delay(5000)]);
await worker.terminate(); // now safe, and no code 13 work remains
}
Verification
Load the pool with tasks and trigger shutdown mid-stream. Success criteria: zero exit code 13 events, every dispatched task id present exactly once across ack and re-enqueue logs, and a drain message from each worker before its exit. Repeat with a deliberately slow worker to prove the timeout path re-enqueues rather than silently dropping the tail of the queue.