Senior Frontend Interview Prep, Part 3: The Event Loop, Scheduling and Paint Timing
Fifteen questions on what actually runs next: microtasks against macrotasks, the rendering opportunity, libuv's phases, the nextTick ordering rule that has an exception nobody mentions, and how to break up a long task without hurting INP.
"What runs first?" is the single most-asked JavaScript interview question, and most answers stop one level above where the interesting part starts. This instalment goes through the scheduling model properly — browser and Node — including one ordering rule that almost every blog post states incorrectly, with the transcript to prove it.
Ordering
31. What is the priority order between synchronous code, microtasks, macrotasks and rendering?
One turn of the loop:
- Run one task to completion. The current script, a timer callback, an event dispatch — whatever it is, the stack unwinds fully. Nothing interleaves.
- Drain the microtask queue to empty.
Promisereactions,queueMicrotask,MutationObserver. Microtasks queued by microtasks are drained in the same pass, which is the crucial part. - If this is a rendering opportunity: run
requestAnimationFramecallbacks, then style recalculation, layout, paint, composite. - Back to step 1 with the next task.
The two things worth saying beyond the list: microtasks drain to empty, not one per turn; and rendering is not a queue you can enqueue into, it is a decision the browser makes (see question 39).
32. What happens if a microtask enqueues another microtask forever?
function forever() {
Promise.resolve().then(forever);
}
forever();Step 2 never terminates. The loop never reaches rendering or the next task. The page freezes hard: no paint, no input, no CSS animation, no way out except the browser's unresponsive-page dialog.
Contrast with the macrotask version:
function alsoForever() {
setTimeout(alsoForever, 0);
}
alsoForever();This one burns CPU but the page stays alive — one task per turn, so rendering and input still get their turn. The asymmetry is the whole point of the question: the microtask queue has no fairness guarantee, because it was never meant to be a scheduler.
33. Exactly when does requestAnimationFrame run?
Inside the rendering opportunity, immediately before style and layout for the frame about to be produced.
| Step | Subsystem | What it does | Why it matters |
|---|---|---|---|
| 1 | Event loop | Task finishes, microtasks drain | Everything before this is already committed |
| 2 | requestAnimationFrame |
Your callbacks run | The last deterministic point to mutate the DOM for this frame |
| 3 | Style | Selector matching, computed styles | Dirtied by anything you did in step 2 |
| 4 | Layout | Geometry and positions | Skipped entirely if you only touched composited properties |
| 5 | Paint & composite | Rasterise, upload, GPU transform | Runs off the main thread |
That is why a DOM mutation inside a rAF callback lands in the very next frame, while the same mutation from a setTimeout lands a frame later — or forces an unscheduled recalculation. And it is why "animate transform and opacity" is not folklore: those two are the properties that let the browser skip steps 3 and 4 entirely.
34. How does requestIdleCallback budget work?
It schedules work for the tail of a frame, after layout, paint and higher-priority tasks have finished. The callback receives an IdleDeadline:
requestIdleCallback(
(deadline) => {
while (deadline.timeRemaining() > 0 && queue.length > 0) {
process(queue.shift());
}
if (queue.length > 0) requestIdleCallback(drain, { timeout: 2000 });
},
{ timeout: 2000 },
);timeRemaining() is capped at 50 ms — long enough to be useful, short enough that an incoming interaction is not delayed past the point a person notices. If the thread never goes idle, the callback may never run at all; { timeout } converts it into an ordinary task once that deadline passes, so the work happens even at the cost of a frame.
Note that it is not supported in Safari, which is why frameworks that need cooperative scheduling ship their own implementation on top of MessageChannel rather than relying on it.
35. How is Node's loop different?
There is no rendering, and there is no single queue. libuv runs discrete phases, in order, every iteration:
- Timers — due
setTimeout/setIntervalcallbacks. - Pending callbacks — I/O callbacks deferred from the previous iteration.
- Idle, prepare — internal.
- Poll — collect new I/O events and run their callbacks. This is where the loop blocks when there is nothing else to do.
- Check —
setImmediatecallbacks. - Close callbacks —
socket.on("close")and similar.
Microtasks are not a phase. They drain after every individual callback, in every phase — which is why await inside an I/O handler does not wait for the next loop iteration.
36. process.nextTick() versus Promise.then() — and the exception
The standard answer: Node keeps two queues. The nextTickQueue is Node's own; the microtask queue is V8's. Node drains nextTickQueue completely first, then the microtask queue. So process.nextTick beats Promise.then, and recursive nextTick starves promises and I/O.
That is correct — except at the top level of an ES module, where almost every article gets it wrong. Here is the same program in both module systems:
// order.cjs
const log = [];
process.nextTick(() => log.push("nextTick"));
Promise.resolve().then(() => log.push("promise"));
queueMicrotask(() => log.push("queueMicrotask"));
setTimeout(() => console.log("CommonJS top level:", log.join(" → ")), 10);// order.mjs — identical body
const log = [];
process.nextTick(() => log.push("nextTick"));
Promise.resolve().then(() => log.push("promise"));
queueMicrotask(() => log.push("queueMicrotask"));
setTimeout(() => console.log("ESM top level:", log.join(" → ")), 10);CommonJS top level: nextTick → promise → queueMicrotask
ESM top level: promise → queueMicrotask → nextTickThe order inverts. An ES module's evaluation is itself a promise job, so by the time your top-level code runs, the microtask queue is already mid-drain; the continuations you queue join that drain, and the nextTick queue is not consulted until it finishes. Move the same three lines inside a setTimeout in the same ESM file and the familiar order returns.
If an interviewer asks this, giving the standard answer and the exception is about as strong a signal as a single question can carry.
37. setImmediate() versus setTimeout(fn, 0) in Node
From the main module, the order is non-deterministic. Both are scheduled before the loop starts; whether the timer is already due when the timers phase is first entered depends on how long process startup took. Run it ten times and you will see both orders.
From inside an I/O callback, the order is guaranteed: setImmediate first. The I/O callback runs in the poll phase, and the very next phase is check. Timers do not come round again until the following iteration.
import fs from "node:fs";
fs.readFile(import.meta.filename, () => {
const order = [];
setTimeout(() => order.push("timeout"), 0);
setImmediate(() => {
order.push("immediate");
setTimeout(() => console.log("inside I/O:", order.join(" → ")), 10);
});
});inside I/O: immediate → timeoutEvery run. That determinism is the reason setImmediate exists at all.
38. Why is setTimeout(fn, 0) clamped to 4 ms?
The HTML specification requires that once the nesting level exceeds 5 — five timers scheduled from inside timer callbacks — the minimum delay is clamped to 4 ms. It exists to stop a zero-delay timer chain from pinning a CPU core and draining a battery.
Two things to add. The clamp is on nesting depth, not on any individual call, so the first few in a chain really are near-zero. And background tabs apply much harsher throttling on top — typically a one-second floor, and after a few minutes some browsers reduce it to roughly one wake-up per minute.
39. When does the browser actually render?
A rendering opportunity happens at the end of a loop iteration only if all of these hold:
- The display is ready for a new frame — roughly 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. The browser paces to the display, not to your code.
- The document is visible. Background tabs and fully occluded windows do not render, and
requestAnimationFramesimply stops firing. - Something changed that requires a paint. No visual change means the whole pipeline is skipped.
That last condition is the one candidates forget, and it is the reason a requestAnimationFrame loop that does nothing visible still costs almost nothing, while the same loop reading getBoundingClientRect costs a great deal.
40. Why scheduler.yield() beats setTimeout(fn, 0) for chunking work
The old trick for splitting a long task is await new Promise((r) => setTimeout(r, 0)). It works, and it has three problems: the nesting clamp adds latency; the continuation goes to the back of the task queue, so unrelated low-priority work can jump the queue ahead of it; and the result is measurably worse interaction latency than it looks like it should be.
scheduler.yield() returns a promise that yields to the browser — letting input and rendering through — and then resumes your continuation at the front of the queue.
async function processAll(items) {
for (const [index, item] of items.entries()) {
handle(item);
if (index % 50 === 49) await yieldToMain();
}
}
function yieldToMain() {
// Chrome/Edge 129+, Firefox 142+. Safari has not shipped it, so this is
// not Baseline and the fallback is not optional.
if ("scheduler" in globalThis && "yield" in globalThis.scheduler) {
return globalThis.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}Ship it with the fallback. Half your users are on a browser that needs it.
41. When do MutationObserver callbacks run?
As microtasks. Mutation records are batched as the DOM changes, and the callback fires when the current script finishes and the stack empties — before style, before layout, before paint.
Contrast that with an ordinary UI event listener, which the browser dispatches as its own task. So a MutationObserver callback sees the DOM before the user ever sees the frame it belongs to, which is what makes it usable for synchronising derived state, and dangerous for anything that mutates the DOM again (you can trivially build an infinite microtask loop out of an observer that writes to what it observes).
42. How do long tasks relate to INP?
The Long Tasks API reports any main-thread task occupying the thread for more than 50 ms. While one runs, no input is processed and no frame is produced.
Interaction to Next Paint measures the whole journey from a user's input to the frame that shows the result:
INP = input delay + processing duration + presentation delay- Input delay — the thread was busy when the input arrived.
- Processing duration — your event handlers.
- Presentation delay — style, layout, paint and composite for the resulting frame.
Google's "good" threshold is 200 ms at the 75th percentile. The trap is optimising only the middle term: a 3 ms click handler that schedules a re-render costing 250 ms of layout fails INP just as hard as a 250 ms handler. That is precisely why INP replaced FID, which only measured the first term of the three.
43. Do microtasks drain between two listeners on the same element?
It depends on who fired the event.
- A real user interaction. Each listener is invoked from its own task-ish context: listener one runs, the stack empties, the microtask queue drains, then listener two runs.
element.click()ordispatchEvent(). The whole dispatch is synchronous within one stack. Both listeners run back to back, and the microtask queue does not drain until the dispatch and its caller have finished.
Which means a test that simulates a click and a real click can genuinely observe different orderings — a fact worth having in your pocket the next time a test passes and production does not.
44. Why do alert() and synchronous XHR freeze everything?
Because they block the thread without unwinding the stack. The frame stays on the stack and the loop never regains control, so no task runs, no microtask drains, and no rendering opportunity is evaluated. Animations stop mid-transition; the page is frozen in whatever state it was in.
This is also the reason alert()-driven debugging changes the timing of the very bug you are chasing, and the reason synchronous XHR has been deprecated on the main thread for a decade.
45. What happens to input that arrives while the thread is busy?
It is not lost. The compositor thread receives it off the main thread and buffers it — which is also how it can keep scrolling a page whose main thread is completely stuck.
When the long task finally yields, the browser dequeues the buffered input, turns it into DOM events and dispatches them ahead of low-priority timers. The user still perceives the delay as lag — the queue time is exactly the input-delay term of INP — but the events themselves arrive intact and in order. Coalescing applies to continuous streams: dozens of buffered pointermove events arrive as a smaller number of events, with the rest retrievable through getCoalescedEvents().
The mental model to carry in
Most scheduling questions collapse to three sentences:
- One task at a time, and the microtask queue drains to empty between tasks.
- Rendering is a decision the browser makes at frame boundaries, not a queue you can push to.
- Anything that blocks without unwinding the stack blocks all three.
Everything else — nextTick, setImmediate, scheduler.yield, the 4 ms clamp — is a refinement of where in that cycle a given callback lands.
Next: scope and closures — execution contexts, the temporal dead zone, and why this still catches out engineers with a decade of experience.