Microtasks and Timers: The Order Your Code Actually Runs
One ordering probe: sync code finishes first, promise callbacks drain in queue order, nested microtasks wait, await resumes as a microtask, and a timer never preempts a loop.
BotBrowser Team
Want the structured docs for Fingerprint?
This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.
BotBrowser supports replaying an authorized same-origin fixture under a declared profile and keeping bounded observations about the order a page ran its callbacks in, but it cannot change how a runtime schedules its own queues, cannot make a timer preempt synchronous code, and cannot turn a callback order into an identity signal. This review runs one ordering probe against a fixed set of shapes and records when synchronous code finishes, when promise callbacks drain, when a microtask queued inside a microtask waits for the queue ahead of it, when an await resumes, where process.nextTick lands relative to a promise, and whether a blocking loop delays a zero-delay timer.
TL;DR
- Synchronous code always finishes first: the probe prints
sync-start,sync-endbefore any promise callback, because the current task runs to completion. - Promise callbacks then drain in the order they were queued, so
promise-1,microtask-1,promise-2follow the scheduling order and the timer comes last. - A microtask queued inside a microtask waits for the microtasks already queued, so the order is
m1,m2,m1-nested,timeout. await nullresumes as a microtask, so the code after theawaitruns before a zero-delay timer.process.nextTickbeats a promise queued after it when both are queued in a timer orsetImmediatecallback, or in a CommonJS script body; a promise orqueueMicrotaskcallback and an ES module body printpromise,nextTickinstead.- A zero-delay timer cannot preempt a synchronous block: the probe's fifty milliseconds loop makes the timer fire at fifty milliseconds or later.
- Repeating the same shape gives the same order every time, so the sequence is reproducible rather than a race.
Contents
- Why synchronous code finishes before any callback
- How promise callbacks drain in scheduling order
- Where a microtask queued inside a microtask waits
- Where await resumes
- Where nextTick lands relative to a promise
- Why a timer never preempts a blocking loop
- Record an ordering reading
Why synchronous code finishes before any callback
JavaScript runs one task at a time, and a task runs to completion before any queued callback starts. The probe pushes a label at the start and end of the synchronous block, queues two promise callbacks and a timer in between, and reads the result:
node=v26.9.0
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0
The two synchronous labels come first, in the order they ran, and every callback comes after. Nothing the synchronous block does can let a queued callback run in the middle of it, because the runtime does not interrupt a running task to start a queued one. That is the foundation of every ordering question below: the current task always owns the thread until it returns.
That rule is why a long synchronous computation freezes a page's timers and promise callbacks. A loop that takes two hundred milliseconds blocks every queued callback for those two hundred milliseconds, and a page that expects a spinner to animate or a timer to fire on schedule sees nothing until the loop returns. The freeze is not a scheduling bug; it is the completion rule working as specified, and the only fix is to break the work into smaller tasks so the runtime can run queued callbacks between them.
The completion rule also decides what a record can claim about a callback order. A callback that ran during the same task as another callback is not a separate observation from the first, because both were queued and drained in one turn. A record that timestamps each callback separately can show the order within a turn, but it cannot show which turn a callback belonged to unless it also records the task boundary, which the next section makes explicit.
How promise callbacks drain in scheduling order
Promise callbacks and queueMicrotask callbacks share one microtask queue, and they drain in the order they were queued, not in the order they were created. The probe queues a promise, a microtask and another promise, then a timer, and prints the drain order:
sync-micro-timer -> sync-start,sync-end,promise-1,microtask-1,promise-2,timeout-0
The two promises and the microtask run in exactly the order they were queued: promise-1, then microtask-1, then promise-2. The timer runs last, after the whole microtask queue is empty. A caller who expects the microtask to jump ahead of the promises because it looks like a lower-level primitive is wrong; the queue is first-in-first-out, and the type of the callback does not change its position.
That ordering is why a chain of .then calls runs one after another in a single turn rather than one per timer. A promise that resolves synchronously queues its callback immediately, and a second .then on the result queues another callback that runs after the first, all before the next timer. A page that awaits a chain of promises and then updates the screen does the whole chain in one turn, which is why the screen update appears all at once rather than in stages.
The drain order is also why an unbounded chain of promise callbacks can starve timers. A callback that queues another promise callback, which queues another, keeps the microtask queue non-empty, and the runtime does not start the next timer until the queue is empty. A loop built from promise callbacks that never stops therefore blocks every timer and every input event, even though no single callback blocks the thread for long. The starvation is the same freeze as the synchronous loop above, arriving through the queue instead of through a loop.
That starvation is what a page's own timing measurement sees as jitter: performance timing fingerprinting shows how a clock read around a queue drain can turn a scheduling delay into a number a collector records.
Where a microtask queued inside a microtask waits
A microtask queued from inside a microtask does not jump ahead of the queue; it appends to the end of the queue and runs after the callbacks already waiting. The probe queues two microtasks where the first one queues a third, and prints the order:
nested-microtask -> m1,m2,m1-nested,timeout
The order is m1, then m2, then m1-nested, then the timer. The nested microtask does not run immediately after m1 that queued it; it waits until m2, which was already queued, has run. Only then does the queue reach m1-nested, and only after the whole queue is empty does the timer run.
That behaviour is the reason a recursive promise chain yields to nothing until it finishes. A callback that awaits its own continuation and queues the next step keeps appending to the microtask queue, and the queue drains one step per pass without ever reaching the timer. A caller who expects the timer to interleave between steps is surprised, because the nested microtask appends rather than interrupts, and the whole chain runs in one turn.
The nested rule is also why a "defer to the next tick" pattern built on promises does not defer past the current turn. A callback that queues a microtask to "let other work run" only lets the microtasks already queued run first; it does not yield to a timer, an input event or a network callback, because all of those wait for the whole microtask queue to empty. Deferring past the current turn needs a timer or a task-queue primitive, not a microtask, and a record that says "deferred to the next tick" without naming which queue is ambiguous.
Where await resumes
An await suspends the async function and resumes it as a microtask, so the code after the await runs after the synchronous block returns and before any timer. The probe awaits null, which resolves immediately, and prints the order:
await-resumes-as-microtask -> async-start,sync,after-await,timeout
The async function starts and pushes async-start, then suspends at the await. The synchronous code after the call pushes sync. The function then resumes as a microtask and pushes after-await. The timer runs last. Even though await null has nothing to wait for, the resumption is still a microtask and still runs after the current synchronous block finishes, which is why the sync label appears between the two halves of the async function.
That gap is why an async function that looks sequential is not synchronous. A caller who calls an async function and expects its first line's side effect to be visible immediately sees the effect, but a caller who expects the effect after the first await to be visible in the same statement does not, because the resumption is queued. The gap is exactly one microtask, which is invisible in a single-threaded reading but visible in any ordering probe that labels both halves.
The resumption order also decides how two async functions interleave. Two async functions that both await an immediately resolved value resume in the order their awaits were reached, because each resumption is a queued microtask. A page that starts two async loads and expects the first one to finish its post-await work first is right as long as the first one reached its await first, and wrong if the second one did. The interleaving is deterministic and depends on the order the awaits were reached, not on the order the functions were defined.
Where nextTick lands relative to a promise
process.nextTick is a Node-specific queue that runs before promise microtasks in most cases, and the probe shows it winning inside a callback:
nextTick-before-promise -> nextTick,promise,timeout
nextTick-beats-later-promise-in-callback -> nextTick,promise
In both cases the nextTick callback runs before the promise callback, and before the timer. Both pairs were queued inside a timer callback, which is a macrotask, and at the end of a macrotask Node drains the whole nextTick queue before it starts the promise queue, so nextTick wins in the second case as well even though the promise was queued first.
The rule is narrower than "nextTick always wins". A follow-up probe queued the identical pair in four contexts and printed nextTick,promise from the CommonJS script body and from a timer callback, then promise,nextTick from inside a promise callback and from inside a queueMicrotask callback. An ES module body behaves like the microtask cases and also prints promise,nextTick. The split follows the context the lines are queued from: a macrotask drains its nextTick queue before the promise queue, while a microtask context is already inside the promise queue, so a continuation queued there runs before control returns to nextTick.
That is why the article's own probe isolates every case in a timer callback, and why both orderings it prints are macrotask results. A probe that queues the pair at CommonJS top level agrees with it; an ES module top level or a microtask callback prints the reverse. A record that says "nextTick runs first" without naming the context has recorded a result that will not reproduce anywhere else.
Why a timer never preempts a blocking loop
A zero-delay timer does not interrupt a running synchronous block; it fires only after the block returns. The probe starts a timer and then runs a fifty milliseconds loop, and checks when the timer fired:
timer-not-preemptive -> elapsed_at_least_50=true
The timer fired at fifty milliseconds or later, not at zero. The runtime cannot preempt the running task, so the timer waits for the loop to finish and then runs on the next turn. The delay is not a bug in the timer; it is the completion rule from the first section applied to a longer block, and the same rule that makes a long computation freeze a page.
The same rule is why a "sleep" built from a busy loop is worse than a timer. A busy loop that waits two hundred milliseconds blocks every queued callback for two hundred milliseconds, while a timer that fires after two hundred milliseconds lets every queued callback run in between. A code path that replaces a timer with a loop to "simplify" the code turns a schedule into a freeze, and a record that shows a timer firing late is the evidence that the loop ran, not that the timer was broken.
The blocking case also sets the expectation for a measurement. A probe that measures a timer's latency after a known block can use the latency as evidence that the block ran, because the timer cannot fire early. A probe that measures latency without controlling the block cannot tell a late timer from a slow machine, so the record has to name the block and its duration alongside the observed latency, or the number means nothing.
The order a callback finally gets is often the order a sort left: Array sort and comparators shows why a default sort orders by string and why a comparator can overflow, so a callback that processes a sorted batch inherits that order rather than the one it was written against.
The determinism check closes the loop on all of the above:
repeat-deterministic -> spt|spt|spt unique=1
Three runs of the same shape give the same order, s, then p, then t, with one unique result. The order is not a race that happens to look stable once; it is the specified behaviour of the queues, and a repeated run confirms it. A probe that found different orders across repeats would be measuring something else, such as a timer whose delay was shorter than the queue drain, and the record would have to explain the variation rather than call it nondeterminism.
Record an ordering reading
An ordering reading supports a narrow claim: this runtime, this shape, with these callbacks queued in this order, produced this sequence. Six fields keep it checkable, and none of them assumes a queue type or a turn boundary:
- The runtime and its event loop, because a browser has no
process.nextTickand orders promise microtasks at the end of the current task instead. - The exact queue each callback was put on, promise,
queueMicrotask,nextTickor timer, because the queue decides the order and the callback type does not. - The order the callbacks were queued in, because the microtask queue is first-in-first-out and a record without the queue order cannot reproduce the drain.
- The turn boundary, whether the shape ran in a macrotask such as a timer or
setImmediatecallback, in a CommonJS script body, or in a microtask such as a promise orqueueMicrotaskcallback, because the same two lines printnextTick,promisefrom the first two andpromise,nextTickfrom the microtask ones. - The synchronous block length, because a blocking loop delays every timer and a record without it cannot explain a late timer.
- Whether the same shape was repeated, because a single run cannot distinguish a specified order from a stable-looking race.
What such a reading cannot show is anything about how a page's ordering interacts with what it reports to a server. A page can run its callbacks in a deterministic order and still send a request whose timing depends on the network, and a server that sees a request arrive cannot infer the page's microtask order from it. For the bytes a callback usually processes, TextDecoder and TextEncoder shows why the same bytes decode differently by label, and for the pattern a callback often runs over them, RegExp lastIndex and flags shows why a reused global pattern flips its answer between calls.
Two habits keep the reading honest over time. First, isolate each ordering case in its own task, because a shared-scope probe can leave work queued and change the next case's order. Second, record the queue type and the turn boundary alongside the sequence, because the same two lines can produce two different orders depending on where they ran. A queue of promises is also not a combinator, and promise combinators shows which of several settled promises the caller actually receives.
Public sources
- MDN: Using microtasks in JavaScript with queueMicrotask()
- MDN: queueMicrotask()
- MDN: await
- MDN: setTimeout()
- Node.js: The Node.js event loop, timers, and process.nextTick()
- BotBrowser: automation consistency reference
Practical conclusion
Treat the queue as part of the ordering contract: synchronous code finishes first, promise and queueMicrotask callbacks drain first-in-first-out, a microtask queued inside a microtask appends to the end, and await resumes as a microtask, so all of that runs before a zero-delay timer. process.nextTick beats a promise queued after it inside a timer or setImmediate callback and inside a CommonJS script body, while a promise or queueMicrotask callback and an ES module body print the reverse, so isolate each case in its own task and name the context in the record. A timer never preempts a blocking loop, which is why a fifty milliseconds loop makes a zero-delay timer fire late. Record the queue type, the queue order, the turn boundary and the block length, and repeat the shape once, so a stored sequence is reproducible rather than a stable-looking race.
Next reading
For how a timing measurement turns into a fingerprint, read performance timing fingerprinting; for the order a callback gets from a sort, read Array sort and comparators.
This article is part of the guide: What Is a Browser Fingerprint?
Related Articles
Promise Combinators: What Fails First
Fingerprintperformance.now: Timer Resolution and Fingerprinting
FingerprintObject Key Order: Integer Keys Sort, String Keys Stay
Platformscheduler.postTask Priorities for Responsive Browser Work
FingerprintUnicode Normalization: Four Text Identities a Page Reads
FingerprintArray Sort and Comparators: A Function You Can Get Wrong
Take BotBrowser from research to production
The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.