Skip to content

process.nextTick order differs from Node.js and truncates Next.js Cache Components responses #7687

Open

Description

@NathanDrake2406

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.

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_date 2026-10-01, compatibility_flags ["nodejs_compat"]
  • Node.js 24.21.0 for the comparison

Activity

  1. jasnell commented on Oct 11, 2026

    @jasnell
    Collaborator

    ... Would you accept a compatibility flag that gives process.nextTick the Node.js order?

    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 setImmediate in workers (it's implemented as setTimeout(fn, 0).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions