Flaky Node.js ordering: setTimeout 0 vs setImmediate vs process.nextTick
A batching test in our Node.js telemetry library failed about one run in twenty on CI and never locally. The code assumed setTimeout 0 fires before setImmediate, which Node does not guarantee in the main module. Moving to ESM later flipped nextTick and promise order too.
Our internal telemetry library batches metrics in memory and flushes them to a collector. One unit test asserted that a "deadline" flush scheduled with setTimeout(fn, 0) ran before a "drain" step scheduled with setImmediate. It passed thousands of times on laptops and failed on CI roughly 5 percent of the time:
AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal:
+ actual - expected
[
+ 'drain',
+ 'deadline'
- 'deadline',
- 'drain'
]
at TestContext.<anonymous> (test/batcher.test.js:41:10)
Retrying the job always "fixed" it, so the failure sat in our flaky-test list for two months until the same ordering assumption caused dropped metrics in production during a deploy.
Reproducing it
The behaviour reduces to four lines run as the main CommonJS script:
// order.cjs
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
for i in $(seq 1 200); do node order.cjs | head -1; done | sort | uniq -c
On an idle laptop:
197 timeout
3 immediate
On a busy CI runner (we ran it alongside stress --cpu 8):
151 timeout
49 immediate
What we ruled out
- Test runner parallelism. The failure reproduced with
node --test --test-concurrency=1. - Fake timers leaking between tests. The file did not use mock timers at all.
- A Node.js bug. The documentation states this order is not deterministic when both are scheduled from the main module. It is behaving as designed.
Root cause
The batcher scheduled its deadline with setTimeout(0) and its drain with setImmediate from code that ran outside any I/O callback. In that situation Node.js does not guarantee which runs first: it depends on whether at least 1 ms has elapsed by the time the event loop first checks the timers phase. Slow or loaded machines cross that 1 ms threshold more often, so the "wrong" order showed up on CI.
What is happening under the hood
Each turn of the Node.js event loop goes through phases in a fixed order: timers, pending callbacks, idle/prepare, poll (I/O), check (setImmediate), and close callbacks. Two more queues are drained between every callback, not per phase: the process.nextTick queue first, then the promise microtask queue.
Three facts explain the flake:
setTimeout(fn, 0)is coerced to 1 ms. A timer is only run in the timers phase if its time has come.- When the main script finishes, the loop starts with the timers phase. If loop startup took less than 1 ms, the timer is not due yet, the loop moves on, and
setImmediateruns in the check phase first. If startup took longer, the timer runs first. - Inside an I/O callback (poll phase), the next phase is always check, so
setImmediateis guaranteed to run before any timer scheduled in that callback.
// deterministic: scheduled from an I/O callback
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate")); // always first
});
The ESM twist: nextTick versus promises
While fixing this we also converted the library to ES modules, and a second ordering test broke immediately and deterministically:
// tick.mjs
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
$ node tick.cjs # same code as CommonJS
nextTick
promise
$ node tick.mjs
promise
nextTick
In CommonJS, the main script runs as a plain callback, and when it returns Node drains the nextTick queue before the microtask queue. An ES module is evaluated as part of a promise job, so microtasks queued during evaluation run within that job, before Node gets a chance to process the nextTick queue. The Node.js documentation points this out and recommends queueMicrotask() over process.nextTick() for most code.
Environment
| Component | Version / value |
|---|---|
| Node.js | 22.12.0 LTS (also reproduced on 20.18.1) |
| OS | Ubuntu 22.04 CI runners, macOS 15 laptops |
| Test runner | Built-in node:test |
| Module format | CommonJS, later migrated to ESM |
| CI runner | 2 vCPU shared runners |
Minimal reproduction
// batcher.js (excerpt, broken)
class Batcher {
constructor(sink) {
this.sink = sink;
this.items = [];
this.events = [];
}
add(item) {
this.items.push(item);
if (this.items.length === 1) {
setTimeout(() => this.deadline(), 0); // must run first, sets batch metadata
setImmediate(() => this.drain()); // assumes deadline already ran
}
}
deadline() { this.events.push("deadline"); this.meta = { flushedAt: Date.now() }; }
drain() { this.events.push("drain"); this.sink(this.items.splice(0), this.meta); }
}
When drain wins the race, this.meta is undefined and the sink drops the batch because it fails validation. That was the production impact.
Who is affected
Any Node.js code or test that relies on the relative order of callbacks in different queues: timers versus immediates from the main module, nextTick versus promises across module formats, or setTimeout delays that happen to be shorter than some other work. It is version-independent; it is how the event loop is specified.
The fix
Do not encode ordering between independent scheduling mechanisms. If B depends on A, call B from A, or await A.
add(item) {
this.items.push(item);
if (this.items.length === 1) {
- setTimeout(() => this.deadline(), 0);
- setImmediate(() => this.drain());
+ setImmediate(() => {
+ this.deadline();
+ this.drain();
+ });
}
}
One scheduled callback, explicit order inside it. The flush still happens after the current synchronous work, so items added in the same tick are batched together.
For the ESM test, we replaced the assertion on cross-queue order with one that awaits the observable outcome:
import { test } from "node:test";
import assert from "node:assert/strict";
import { setImmediate as nextLoop } from "node:timers/promises";
import { Batcher } from "../src/batcher.js";
test("flushes one batch with metadata", async () => {
const batches = [];
const b = new Batcher((items, meta) => batches.push({ items, meta }));
b.add(1);
b.add(2);
await nextLoop(); // resolves after the check phase of this turn
assert.equal(batches.length, 1);
assert.deepEqual(batches[0].items, [1, 2]);
assert.ok(batches[0].meta.flushedAt > 0);
});
We also swept the library for process.nextTick used to "run before promises" and replaced it with queueMicrotask where ordering mattered within our own code, so behaviour is the same in CommonJS and ESM.
Alternatives and trade-offs
- Run the scheduling from an I/O callback to make the order deterministic. Technically correct, but fragile: a refactor that moves the call site out of the callback silently restores the race.
- Mock timers in tests (
mock.timersinnode:test). Makes the test deterministic but can hide the production race, because the mock decides the order instead of the real loop. We use mock timers for long delays, not for ordering questions. - Increase the timeout to, say, 5 ms. Shrinks the window without closing it, and adds latency.
How to test the fix
We run the ordering-sensitive test file 500 times under CPU pressure to give the race a fair chance:
stress --cpu 8 --timeout 120 &
for i in $(seq 1 500); do
node --test test/batcher.test.js > /dev/null || { echo "failed on run $i"; exit 1; }
done
echo "500 runs passed"
Before the fix this failed within the first 30 runs every time. After it, 500 of 500 passed, in both CommonJS and ESM builds.
Confirming in production
The collector rejects batches without metadata and counts them in a batch_rejected_total{reason="missing_meta"} metric. Before the fix it showed small spikes during each deploy, when new processes started under heavy CPU load. In the three deploys after release it stayed at zero, and the sum of metrics received matched the sum emitted by the canary within 0.01 percent.
Regression guard
- The stress loop runs nightly in CI for every test file tagged as timing-sensitive.
- An ESLint
no-restricted-syntaxrule flags asetTimeoutwith a zero delay in the library source, with a message pointing to this incident. - The flaky-test policy changed: a test that fails and then passes on retry opens a ticket automatically instead of being retried silently.
Security implications
None beyond data integrity. Dropped telemetry batches meant gaps in metrics that our alerting relies on, which is an availability concern for monitoring rather than an exposure risk. The fix does not change what data is collected or where it is sent.
Prevention checklist
- Never rely on the order of
setTimeout(0)versussetImmediatescheduled outside I/O callbacks. - Do not depend on
process.nextTickrunning before promises; it differs between CommonJS and ESM. - Chain dependent steps explicitly or await them instead of scheduling them separately.
- Stress-test timing-sensitive tests under CPU load before trusting them.
- Treat "passes on retry" as a bug report, not a fix.
References
- Node.js docs, The Node.js Event Loop: https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
- Node.js API, process.nextTick() and queueMicrotask(): https://nodejs.org/api/process.html#processnexttickcallback-args
- Node.js API, Timers: https://nodejs.org/api/timers.html
- Node.js API, Test runner mocking timers: https://nodejs.org/api/test.html
Add a label
Embed this post
<iframe src="https://www.hibug.me/nodejs-settimeout-vs-setimmediate-order" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Related Stories
Design patterns in TypeScript that actually paid off: strategy, adapter and DI
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.