Repository navigation
process.nextTick order differs from Node.js and truncates Next.js Cache Components responses #7687
Description
This is a feature proposal for nodejs_compat.
Summary
process.nextTick(callback) calls queueMicrotask(callback) (source). Node.js has a separate tick queue. It runs all ticks, then all microtasks, and repeats until the two queues are empty (documentation).
Next.js needs the Node.js order to render Cache Components. On workerd, runtime prefetch responses are shorter than on Node.js.
What is different
| Case | Node.js 24.21.0 | workerd 1.20261006.1 |
|---|---|---|
Sync code schedules a tick (syncTickOrder) |
tick, promise, microtask |
promise, microtask, tick |
A microtask schedules a tick (tickOrder) |
promise chain done, tick |
tick, promise chain done |
Staged render, as in Next.js (stagedRender) |
stage 1, flush stage 1, stage 2 |
stage 1, stage 2, flush stage 1 |
Reproduction (four files)
Both runtimes run order.mjs. Run npx wrangler dev and request /. Then run node node.mjs.
// order.mjs
// Both the Worker and the Node.js script import this file.
// A tick that sync code schedules, inside a timer callback.
export function syncTickOrder() {
return new Promise((resolve) => {
setTimeout(() => {
const log = [];
Promise.resolve().then(() => log.push("promise"));
queueMicrotask(() => log.push("microtask"));
process.nextTick(() => log.push("tick"));
setTimeout(() => resolve(log), 0);
}, 0);
});
}
// A tick that a microtask schedules.
export async function tickOrder() {
const log = [];
(async () => {
for (let i = 0; i < 10; i++) await null;
log.push("promise chain done");
})();
queueMicrotask(() => process.nextTick(() => log.push("tick")));
await new Promise((resolve) => setTimeout(resolve, 0));
return log;
}
// The sequence Next.js uses for a staged render, reduced. Two timers are queued
// up front. After the first one, Next.js collects the immediates that the render
// schedules and runs them before the second timer.
export async function stagedRender() {
const log = [];
const nativeSetImmediate = globalThis.setImmediate;
let captured = null;
globalThis.setImmediate = (callback) => (captured ? captured.push(callback) : nativeSetImmediate(callback));
// Run the collected immediates when the ticks and microtasks of this task are done.
const drainAfterTicksAndMicrotasks = () =>
queueMicrotask(() =>
process.nextTick(() => {
const next = captured.shift();
if (!next) {
captured = null;
return;
}
next();
drainAfterTicksAndMicrotasks();
})
);
let openStage;
const stageOpened = new Promise((resolve) => (openStage = resolve));
// A component that waits for the stage, then awaits a few more times before it renders.
(async () => {
await stageOpened;
for (let i = 0; i < 10; i++) await null;
setImmediate(() => log.push("flush stage 1"));
})();
setTimeout(() => {
captured = [];
drainAfterTicksAndMicrotasks();
log.push("stage 1");
openStage();
}, 0);
setTimeout(() => log.push("stage 2"), 0);
await new Promise((resolve) => setTimeout(resolve, 20));
globalThis.setImmediate = nativeSetImmediate;
return log;
}// worker.mjs
import { stagedRender, syncTickOrder, tickOrder } from "./order.mjs";
export default {
async fetch() {
return Response.json({
syncTickOrder: await syncTickOrder(),
tickOrder: await tickOrder(),
stagedRender: await stagedRender(),
});
},
};// wrangler.jsonc
{ "name": "next-tick-order", "main": "worker.mjs", "compatibility_date": "2026-10-01", "compatibility_flags": ["nodejs_compat"] }// node.mjs
import { stagedRender, syncTickOrder, tickOrder } from "./order.mjs";
console.log(
JSON.stringify({
syncTickOrder: await syncTickOrder(),
tickOrder: await tickOrder(),
stagedRender: await stagedRender(),
})
);Why Next.js needs the Node.js order
Next.js renders a Cache Components page in stages. Each stage is a timer (runInSequentialTasks). Between two timers, Next.js runs the immediates that React scheduled. React then flushes each stage before the next stage starts.
To find the end of a task, Next.js calls queueMicrotask(() => process.nextTick(...)) (source). In Node.js, this callback runs when the microtask queue is empty (processTicksAndRejections).
In workerd, the callback runs while a component is still in its promise chain. The component has not called setImmediate yet, so Next.js finds no immediates and stops collecting them. The flush then runs after the timer of the next stage. The third row of the table shows this.
Effect on Workers
I served one Next.js 16.3.8 build with next start and with a Worker built by @opennextjs/cloudflare 1.20.10. I sent a runtime prefetch request to 8 routes and masked the values that change on every render.
- All 8 responses were shorter on the Worker. One example is 3,746 bytes on the Worker and 4,385 bytes on Node.js.
- With a replacement staged runner in the adapter (fix: run Cache Components staged renders correctly on workerd opennextjs/opennextjs-cloudflare#1318), all 8 responses are byte-identical to Node.js.
Next.js 16.4 enables Cache Components by default in create-next-app (announcement).
Why an adapter cannot fix this fully
A Worker can learn that the microtask queue is empty only if it waits for a macrotask. A macrotask between two stages lets the timers and I/O of the same request run there. In Node.js, the stage timers run one after the other in the same timers phase, and I/O callbacks cannot run between them.
On workerd, a 1 ms timer fires between two chained setImmediate callbacks. I think this explains the next result, but I did not trace it. With 192 concurrent requests and the replacement runner, a runtime prefetch included the output of a component that awaits setTimeout(50). Node.js leaves that output out and returns a partial prefetch.
Request
The comment in internal_process.ts says that the approximation is deliberate. Would you accept a compatibility flag that gives process.nextTick the Node.js order?
- A tick that sync code schedules runs before promise callbacks.
- A tick that a microtask schedules runs when the microtask queue is empty.
- The two steps repeat until the two queues are empty.
The error handling of process.nextTick and the _idleStart field of timers are not part of this request.
I did not test unmodified Next.js with a changed process.nextTick. I found no existing flag or issue for the tick order.
Environment
- workerd 1.20261006.1 through wrangler 4.149.0, on macOS arm64
compatibility_date2026-10-01,compatibility_flags["nodejs_compat"]- Node.js 24.21.0 for the comparison
Activity
jasnell commentedon Oct 11, 2026
I'd don't think we'd ever say never but it's unlikely to be something we'd prioritize. Introducing a node.js equivalent nextTick queue would be non-trivial at best. There's also a difference with
setImmediatein workers (it's implemented assetTimeout(fn, 0).fix: flush each Cache Components render stage before the next stage o…