process.nextTick vs promise microtasks, and starvation
nextTick is not the next loop turn: Node empties the tick queue, then the promise queue, again and again, before the loop moves on.
Predict first
Before you read: predict
ExamHall's proctor console server receives candidate events on a socket. Its 'data' handler is below.
In what order do the five log lines print?
socket.on('data', (evt) => { setImmediate(() => log('forward')); Promise.resolve().then(() => { log('dedupe'); process.nextTick(() => log('audit')); }); process.nextTick(() => log('parse')); queueMicrotask(() => log('count'));});The 30-second version
process.nextTick has its own queue, separate from promise microtasks. When a callback returns, Node runs every queued nextTick, including new ones, then every microtask, including new ones, and repeats until both are empty. Ticks added while microtasks run come after all of them. Top-level ES module code already runs inside a microtask pass.
nextTick is not the next loop turn: Node empties the tick queue, then the promise queue, again and again, before the loop moves on.
Step through the code
Trace of the opening question pattern
Play options
Ready: press Play or step with Next frame
Row 1 of 4 · After the handler returns
Starting row · computed by comparing each row with the one before
- After: the handler returns
- Tick queue: one tick
- Microtask queue: two callbacks
- Runs: the tick
Rule · step 2
After each callback (the main script, a timer, an I/O or setImmediate callback), Node runs every queued nextTick, including ticks added meanwhile.
- After
- the handler returns
- Tick queue
- one tick
- Microtask queue
- two callbacks
- Runs
- the tick
- Step
- 2
| After | Tick queue | Microtask queue | Runs | Step |
|---|---|---|---|---|
| the handler returns | one tick | two callbacks | the tick | 2 |
| the tick queue is empty (changed) | empty (changed) | two callbacks | both, in FIFO order; the first queues a tick (changed) | 3, 4 |
| the microtask queue is empty (changed) | one tick (changed) | empty (changed) | the new tick (changed) | 4, 2 |
| both are empty (changed) | empty (changed) | empty | the loop moves on (where the immediate runs: node-event-loop-phases) (changed) | 6 |
Space plays or pauses · ←/→ one row · Home/End first/last
Rows come from the pack's own trace table: a generic handler; TRUTH rows of model.mjs.
The probe code (rows are not linked to code lines)
socket.on('data', (evt) => { setImmediate(() => log('forward')); Promise.resolve().then(() => { log('dedupe'); process.nextTick(() => log('audit')); }); process.nextTick(() => log('parse')); queueMicrotask(() => log('count'));});How it works
Step 1 of 12
await continuations and queueMicrotask, as the prerequisite describes.See it run
Not in this run
Analogy: A town council meeting
The chair runs the meeting by one rule about two lists.
| In the council chamber | In Node |
|---|---|
| when a speaker sits down, the chair first hears every point of order, including ones raised meanwhile | the tick queue drains after each callback (step 2) |
| then every follow-up question on the list, in sign-up order | the microtask pass, FIFO (step 3) |
| a point of order raised during questions is heard once the question list is empty | a tick queued in a microtask waits (step 4) |
| the next agenda item and the public gallery wait until both lists are empty | the loop continues only then (step 6) |
| a member who raises a new point of order in every point of order stalls the meeting | recursive nextTick starves everything (step 7) |
| the chair's opening address is given during question time | ES-module top level (step 10) |
In real terms: after every callback Node empties two queues in turn, ticks first, and the loop waits for both.
Breaks down when: a real chair can rule a point of order out of order. Node has no cap: the docs warn that recursive nextTick can create an infinite loop.
Contrasting case: the browser has no tick queue. There, promise callbacks and queueMicrotask share the only microtask queue, the prerequisite's model.
Examples, run for real
4.1 ExamHall: the proctors' dashboard froze
EXH-1330: the proctor console server dedupes a backlog of candidate events in batches, inside a socket callback. Each batch re-scheduled the next with process.nextTick. A 50 ms timer pushes the proctors' live dashboard; while the backlog lasted, it never fired, and the dashboard froze. MR !1530 re-schedules with setImmediate.
const step = () => { dedupeBatch(); if (more()) reschedule(step); }; // 100 batches, 10 ms of CPU each
setInterval(pushDashboard, 50); // the proctors' live view
4.1 process.nextTick (as shipped) batches 100/100 · dashboard pushes during dedupe: 0
4.1 setImmediate (MR !1530) batches 100/100 · dashboard pushes during dedupe: at least 1
With nextTick the tick queue never empties, so the loop never reaches its timers (step 7). Why setImmediate lets the loop move on is node-event-loop-phases step 7. The work and its total CPU are unchanged; only who waits changes.
4.2 Where the entry module type matters (fixtures/entry.cjs, fixtures/entry.mjs)
The same three lines, then, nextTick, queueMicrotask, run at the top level and again inside a setImmediate callback, in a CommonJS file and in an ES module:
4.2 CommonJS top level nextTick · then · microtask
4.2 CommonJS inside a setImmediate nextTick · then · microtask
4.2 ES module top level then · microtask · nextTick
4.2 ES module inside a setImmediate nextTick · then · microtask
Only the ES module's top level differs: it is already inside a microtask pass, so the tick waits (steps 4, 10). Inside a callback both agree (step 11). A test that pins an order at the top level of an .mjs file can pass while the same code, run from a request handler, behaves differently.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 4
A chat server's 'data' handler first calls readState.then(markRead), where readState is an already-resolved promise. Then it starts a fan-out: fanOut(i) sends the message to member i, then calls process.nextTick(() => fanOut(i + 1)), until all 2,000 members have it.
After how many sends does markRead run?
Question 2 of 4
A payment SDK's Gateway constructor calls process.nextTick(() => this.emit('ready')). A CommonJS checkout service has async function openCheckout() { const gw = new Gateway(); const rates = await cachedRates; gw.on('ready', startTaking); }, where cachedRates is an already-resolved promise and startTaking needs rates, so the listener must stay after the await. Its unit test, an ES module, calls await openCheckout() at its top level, and startTaking runs. In production, openCheckout() runs inside a request's 'data' callback, and startTaking never runs.
Which one change makes production work and keeps the test passing?
Question 3 of 4
A box office's 'data' handler starts a seat sweeper with queueMicrotask(sweep); sweep frees one expired hold and re-queues itself with queueMicrotask until 3,000 are freed. Next, the handler calls payment.then(() => sdk.confirm()) on an already-settled payment, and confirm emits 'settled' from process.nextTick. 'settled' fires only after all 3,000 holds are freed.
Which is the bug?
Question 4 of 4
A streaming relay, a CommonJS app, reads a stream's rows from its database inside a request handler. In the .then callback that receives the rows, it schedules the stream's end with process.nextTick(() => stream.emit('end')), and then calls a helper that defers a cleanup callback. cleanup must run after the current callback returns and before 'end' is emitted.
Which API should the helper use?
Go deeper
Pro tips
- Pin an ordering test inside a callback, not at the top level of an
.mjstest file. Wrap the code under test insetImmediate(…)or a real I/O callback, where production runs it. When: unit tests of event emitters, SDK start-up and anything usingnextTick(steps 10–11). Verify (RAN,fixtures/entry.*, 100 runs): the ES module's top level printedthen · microtask · nextTick; insidesetImmediate,nextTick · then · microtask. - Search hot paths for a
nextTickthat re-queues itself. It holds back promise callbacks, timers and I/O until the chain ends; re-schedule long work asnode-blocking-event-loopdescribes. When: batch jobs, dedupe, fan-outs in a server (step 7). Verify: withnextTick, the 50 ms dashboard timer fired 0 times during a 100-batch loop.
Source
- Node.js v24 API, process:
process.nextTick(callback[, ...args])and "When to usequeueMicrotask()vs.process.nextTick()": https://nodejs.org/docs/latest-v24.x/api/process.html#processnexttickcallback-args · sourcedoc/api/process.mdat tag v24.19.0 (https://raw.githubusercontent.com/nodejs/node/v24.19.0/doc/api/process.md), fetched 2026-09-28. "Stability: 3 - Legacy: UsequeueMicrotask()instead", since v22.7.0 and v20.18.0 (step 12). The queue "is fully drained after the current operation on the JavaScript stack runs to completion and before the event loop is allowed to continue. It's possible to create an infinite loop if one were to recursively callprocess.nextTick()" (steps 2, 7). "every time the 'next tick queue' is drained, the microtask queue is drained immediately after" (step 3). "So in CJS modulesprocess.nextTick()callbacks are always run beforequeueMicrotask()ones. However since ESM modules are processed already as part of the microtask queue, therequeueMicrotask()callbacks are always executed beforeprocess.nextTick()ones". "When in doubt, unless the specific capabilities ofprocess.nextTick()are needed, usequeueMicrotask()" (step 12). The constructor-emit pattern is the page'sMyThingexample. Valid as of 2026-09-28. - Node.js, The Node.js Event Loop (section
process.nextTick()): https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick · fetched 2026-09-28. "thenextTickQueuewill be processed after the current operation is completed, regardless of the current phase of the event loop" (step 2); "it allows you to 'starve' your I/O by making recursiveprocess.nextTick()calls, which prevents the event loop from reaching the poll phase" (step 7); "process.nextTick()fires immediately on the same phase;setImmediate()fires on the following iteration" (step 6). Valid as of 2026-09-28. - Node.js v24 API, globals:
queueMicrotask(callback): https://nodejs.org/docs/latest-v24.x/api/globals.html#queuemicrotaskcallback · source at tag v24.19.0, fetched 2026-09-28. "The microtask queue is managed by V8" (step 1). Valid as of 2026-09-28.
Related lessons
Checked against the tools and docs as of 2026-09-28.