Browser Event Loop: How Tasks, Microtasks, and Rendering Are Scheduled
Understand run-to-completion, tasks, microtasks, rendering opportunities, and why Node.js has a different scheduling model.
Personal learning atlas by Tran Trong Thuc · About this Atlas · Atlas last updated Sep 22, 2026
TL;DR
A single innocent recursive Promise.resolve().then(...) chain or runaway queueMicrotask() loop can completely freeze an entire browser tab or stall a Node.js process—without executing a single synchronous while(true) statement. Because the JavaScript runtime refuses to yield to the Macrotask Queue or the browser's Rendering Pipeline until the Microtask Queue is completely drained, continuous microtask creation starves user input, timers, and screen redraws. Mastering how the Call Stack, Microtasks, Tasks/Macrotasks, and requestAnimationFrame interleave is the key to preventing UI lockups and debugging complex async ordering.
💡 Rule of thumb: Synchronous code executes on the Call Stack to completion first, followed immediately by draining the entire Microtask Queue down to zero. Only then can the browser consider a Rendering Opportunity or dispatch the next Macrotask from a Task Source.
- Call Stack runs to completion: The currently executing JavaScript frame cannot be interrupted by queued event-loop callbacks. Synchronous statements finish completely before any asynchronous callback runs.
- Microtasks drain completely at checkpoints: Fulfilled Promise reactions and
queueMicrotask()callbacks execute as soon as the Call Stack empties. Any new microtasks enqueued during the checkpoint run in the same cycle before yielding. - Rendering is decoupled from task execution: Browsers do not paint after every callback. Visual updates occur only during Rendering Opportunities synced to the display refresh rate (e.g., 60Hz/120Hz), with
requestAnimationFrame()callbacks firing immediately prior to style recalculation and layout. - Macrotasks dispatch from independent Task Sources: Timers (
setTimeout), user input, and network I/O reside in separate queues prioritized by the browser scheduler, rather than a single flat FIFO queue. - Fatal pitfall (Microtask Starvation): Generating microtasks recursively traps the engine in an endless microtask checkpoint, starving macrotasks, user clicks, and the rendering pipeline entirely. Even though the call stack unwinds periodically, the tab freezes solid.
Start with one concrete example
Predict this output before learning any event-loop vocabulary:
console.log('A');
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('B');In a browser, the relevant output is:
A
B
promise
timerThe useful first model is simple:
- the current JavaScript keeps running, so
AandBappear first; - the already-fulfilled Promise schedules its callback for the browser's microtask processing;
- the timer callback is later regular event-loop work;
- when the current JavaScript finishes, the browser processes the queued microtask before that later timer task.
Everything else on this page makes that model more precise without changing the basic intuition.
Run-to-completion
Current task
Microtask queue
Later browser work
For ordinary browser application code, the browser does not start an unrelated event-loop callback in the middle of the JavaScript that is already executing for the current task.
console.log('A');
console.log('B');
console.log('C');Those synchronous statements finish before a later timer, click callback, or other unrelated event-loop task takes over.
This is the “run-to-completion” part of the model. It does not mean the browser can never do work concurrently elsewhere; it means unrelated event-loop JavaScript does not interleave statement-by-statement inside the current task's JavaScript execution.
Microtasks and checkpoints
The browser keeps microtasks separate from regular task queues. When the browser reaches a point where the HTML processing model requires a checkpoint, queued microtasks are processed until the microtask queue becomes empty.
- Current task finishes
- Run next microtask
- New microtask queued?If yes, keep draining
- Queue emptyBrowser may continue scheduling / rendering
A nested microtask shows the “until empty” rule:
console.log('script');
queueMicrotask(() => {
console.log('microtask A');
queueMicrotask(() => {
console.log('microtask B');
});
});The relevant order is:
script
microtask A
microtask Bmicrotask B does not wait for an unrelated later task. It was added while the same microtask checkpoint was still draining.
For ordinary browser reasoning, this simplified flow is useful:
current browser task
│
│ JavaScript runs to completion
▼
microtask checkpoint
│
│ queued microtasks drain until empty
▼
browser continues scheduling
│
├─ later runnable task work can be selected
│
└─ rendering-related work may be consideredPromise reactions and queueMicrotask()
An already-fulfilled Promise still does not call its then() handler inline:
console.log('before');
Promise.resolve().then(() => {
console.log('promise reaction');
});
console.log('after');Output:
before
after
promise reactionThe synchronous code finishes first. The Promise reaction is processed later as browser microtask work.
queueMicrotask() gives browser code a direct way to queue microtask work without constructing a Promise solely for deferral:
queueMicrotask(() => {
reconcileCachedState();
});One useful case is making two branches of an API expose consistent asynchronous ordering when one branch already has data and another obtains it asynchronously.
Microtasks are not a free “run sooner” priority lane. Their work still consumes time, and excessive microtask work can delay later event-loop progress.
Timers mean later, not exactly on time
A timer delay is not an execution deadline.
setTimeout(callback, delay) arranges timer-based task work after the timer rules allow it to become runnable. The callback can start later than the requested delay because JavaScript may still be running, microtasks may still be draining, other tasks may be selected, the browser may throttle timers, or the system may be busy.
So:
setTimeout(doWork, 0);means approximately:
Make
doWorkeligible for later timer callback work as soon as the timer rules allow.
It does not mean:
Interrupt the JavaScript that is currently running and execute
doWorknow.
Likewise, setTimeout(doWork, 100) does not guarantee that doWork starts exactly 100 milliseconds later. If correctness depends on an exact wall-clock start time, a timer alone is the wrong synchronization mechanism.
Tasks are not one universal FIFO queue
The simple model above is enough for many application-level questions. The HTML specification adds an important nuance when you compare unrelated kinds of regular browser work.
The HTML Standard uses task as the regular event-loop unit. “Macrotask” is common informal vocabulary, but it is not the core HTML-standard term.
A browser event loop has one or more task queues. Formally, an HTML task queue is modeled as a set of tasks, because the processing model chooses a runnable task queue and then takes the first runnable task from that chosen queue. The microtask queue is explicitly separate.
For a given event loop:
- every task source is associated with a task queue;
- multiple task sources may be associated with the same task queue;
- a browser may use different queues to favor categories such as user interaction while still respecting required ordering;
- tasks from one task source retain the ordering guarantees required by that source;
- unrelated task sources do not automatically gain one universal cross-source FIFO guarantee.
So this model is too strong:
one global macrotask queue
[ timer ][ click ][ network ][ message ][ ... ]
always run the globally oldest itemA safer teaching picture is:
runnable browser task work
user interaction [ click ]
timers [ timeout ]
networking [ response ]
...
browser chooses runnable task-queue work where the
platform leaves that choice implementation-defined,
while preserving the ordering guarantees that do applyThe lanes are teaching labels, not a claim that a browser must implement exactly one physical queue per task source.
Rendering opportunities and requestAnimationFrame()
Rendering is where many event-loop diagrams become misleading. A common simplified story says:
task -> all microtasks -> paint -> next taskThat is not a guaranteed per-task paint sequence.
- Task + microtask checkpoint
- Rendering opportunityBrowser may skip it
- requestAnimationFrameBefore style/layout for the frame
- Style → layout → paint
- Composite / present
The HTML processing model lets the browser determine rendering opportunities. Current HTML processing can queue rendering-update work on a rendering task source when a window has a rendering opportunity, and the specification explicitly allows tasks to run back-to-back with microtask checkpoints but no intermediate rendering update.
requestAnimationFrame() participates in rendering-update processing:
requestAnimationFrame((timestamp) => {
updateVisualPosition(timestamp);
});The callback is associated with browser rendering work. It is not a generic “callback that always runs immediately after microtasks,” and it is not the browser equivalent of Node.js setImmediate().
When reasoning about UI code, prefer:
A rendering opportunity may cause rendering-update work to run around this point.
Do not claim:
The browser definitely paints here.
unless a stronger API-specific guarantee supports that statement.
Event Loop Lab
The lab below uses a deterministic teaching model rather than executing arbitrary JavaScript. That makes platform guarantees and deliberately unspecified browser choices visible instead of presenting one browser run as a universal law.
It includes six scenarios:
- Promise reaction vs timer — shows
A,B, Promise reaction, then timer. - A microtask queues another microtask — shows that a checkpoint drains newly produced microtasks before becoming empty.
- A timer queues a Promise reaction — shows the microtask checkpoint after the timer task before later task work continues.
- Rendering opportunity +
requestAnimationFrame()— places animation-frame work inside a simplified rendering update instead of a fake post-microtask queue. - Bounded microtask starvation — models a self-producing microtask chain for five iterations, then stops with a warning instead of freezing the Atlas page.
- Multiple runnable task sources — deliberately asks you to choose between two valid runnable sources instead of inventing a universal cross-source order.
The visual state is supplementary. The scheduling rules and expected reasoning remain in this page's Markdown.
Event Loop Lab
console.log('A');
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('B');Currently running work
initial script
Microtasks
Empty
Rendering-related work
Rendering state: Running selected work
requestAnimationFrame callbacks
Empty
Runnable task-source work
Empty
Empty
Empty
Empty
Empty
These lanes group runnable work by task source for teaching. They do not imply that every task source maps one-to-one to a browser task queue; user agents may coalesce task sources into task queues.
Output log
No output yet
Why this step?
The initial script is the currently selected work.
Multiple task sources and scheduler choice
Suppose a simplified browser state has both of these runnable:
Timer source [ timer callback ]
User-interaction source [ click callback ]If the only information you know is that these tasks come from unrelated sources and are runnable at the same time, do not invent a global FIFO relationship that the platform has not promised.
The HTML event-loop processing model allows the user agent to choose among task queues with runnable work in an implementation-defined manner. Task sources can be mapped to those queues in different ways, and a browser can use that freedom to keep interfaces responsive while preserving required source ordering.
That is why the lab's scheduler-choice scenario accepts either initial source. This does not mean browser ordering is random. It means correct code should rely only on ordering guarantees supplied by the relevant API/specification, not on a guessed relationship between unrelated task sources.
If operation B must happen after operation A, encode that dependency directly with data flow, a Promise, an event, a state transition, or another explicit synchronization mechanism.
Starvation and responsiveness
Two different scheduling problems can keep later work from progressing.
Long synchronous work
button.addEventListener('click', () => {
expensiveCpuLoop();
});While this callback keeps executing JavaScript in the current task, later input callbacks and rendering work do not interleave in the middle of it. The page can feel frozen even when no network request is involved.
The appropriate fix depends on the workload: reduce the work, choose a better algorithm, split suitable work into intentionally scheduled chunks, or move appropriate CPU work to a worker.
A microtask chain that keeps replenishing itself
Long synchronous task
Unbounded microtask chain
function again() {
doSmallThing();
queueMicrotask(again);
}
queueMicrotask(again);Each callback queues another microtask before the checkpoint becomes empty. An unbounded chain can therefore delay later regular tasks and rendering-related work.
The Atlas simulator intentionally stops after five iterations. Do not copy the unbounded example into a real page as a general scheduling technique.
Microtasks are not inherently harmful. The problem is unbounded or excessive work inside one checkpoint before the event loop can make other progress.
How Promise Jobs enter a browser microtask queue
The earlier sections deliberately used browser-facing vocabulary first. The formal JavaScript/browser boundary introduces another layer only after the observable scheduling model is clear.
ECMAScript defines host hooks such as HostEnqueuePromiseJob(). For HTML browsers, HTML specifies how Promise-related Jobs are integrated with browser microtask processing.
This division of responsibility explains why Promise semantics belong to JavaScript while the exact event-loop integration belongs to the surrounding runtime/platform.
Browser versus Node.js
The scheduling model on this page is specifically a browser/HTML event-loop model, not a universal JavaScript-runtime diagram.
Browsers and Node.js both execute JavaScript and both process Promise microtasks, but their surrounding runtime scheduling systems are different.
| Browser | Node.js |
|---|---|
| HTML event loop and browser task sources | Node.js event loop and runtime-specific scheduling machinery |
| browser rendering opportunities and rendering pipeline | no browser rendering pipeline |
requestAnimationFrame() for rendering-related callbacks | setImmediate() is a Node-specific scheduling API, not an rAF analogue |
| browser platform APIs | Node APIs may involve libuv and other Node runtime facilities |
Node's timer APIs intentionally resemble browser timers at the API surface, but Node documents their implementation in terms of the Node.js Event Loop, not the HTML event loop.
Node also provides process.nextTick(). Current Node.js documentation marks process.nextTick() Legacy and recommends queueMicrotask() for most userland deferral. process.nextTick() also has Node-specific scheduling behavior, so it should be learned from the Node runtime model rather than copied into a browser diagram.
When moving scheduling-sensitive code between runtimes, ask two separate questions:
- Which behavior comes from ECMAScript itself—for example Promise resolution and Jobs?
- Which ordering behavior comes from the current runtime—for example browser task sources/rendering or Node-specific queues and phases?
That separation prevents “it worked in Chrome” from becoming evidence about Node.js ordering, and vice versa.
Production considerations
Production scenario: The "invisible" microtask UI freeze
A real-time chat application ingests batches of incoming messages over a WebSocket connection. When a burst of 500 messages arrives at once, a developer attempts to chunk the processing using recursion:
function processMessageBatch(messages: Message[]) {
if (messages.length === 0) return;
const msg = messages.shift()!;
renderMessage(msg);
// Attempting to "yield" with queueMicrotask instead of a synchronous loop:
queueMicrotask(() => processMessageBatch(messages));
}- Impact: Despite having no single long synchronous
forloop blocking the call stack, the user interface freezes completely for 1.2 seconds! Click handlers on the "Send" button and text inputs fail to respond. Interaction to Next Paint (INP) flags a critical latency spike (>1000ms). - Root cause: The developer conflated
queueMicrotask()with yielding control back to the browser. A microtask checkpoint never yields control to macrotasks or the browser's rendering pipeline until the microtask queue is completely drained. - Correct pattern: To yield control to the browser for layout updates, paint cycles, and user input, break work across macrotasks or use the modern standard scheduler API:
// Modern standard: scheduler.yield() when available, falling back to a zero-delay macrotask timer async function processMessageBatch(messages: Message[]) { for (const msg of messages) { renderMessage(msg); if ('scheduler' in window && 'yield' in (window as any).scheduler) { await (window as any).scheduler.yield(); } else { await new Promise((resolve) => setTimeout(resolve, 0)); } } }
Keep main-thread work bounded. A long browser task can delay user input and rendering. Measure hot paths, reduce unnecessary CPU work, split appropriate work, or move suitable computation to workers.
Keep microtask work bounded. Promise-heavy code is normal, but recursively generating work during one checkpoint can delay later progress. Do not use an endless microtask chain as a generic scheduler.
Do not use timer precision for correctness. Timers provide best-effort later scheduling, not hard real-time deadlines. If a deadline matters, compare actual timestamps/state when the callback runs and design for lateness.
Do not synchronize through guessed queue priority. If two operations require ordering, express the dependency explicitly instead of hoping one unrelated task source wins browser selection.
Separate responsiveness from throughput. Breaking CPU work into smaller chunks can improve responsiveness even when total work remains similar. Conversely, creating more asynchronous callbacks does not make expensive CPU work disappear.
Profile the real browser. DevTools performance traces, long-task observations, browser performance APIs, and application timings provide better evidence than counting Promise or timer calls in source code.
Keep this page's scope in mind. Dedicated workers have their own event-loop context and can have rendering-related capabilities of their own. This lesson focuses on window/browser application scheduling rather than every worker processing model.
Exercise
Predict the output of this browser example before reading the answer:
console.log('A');
setTimeout(() => {
console.log('timer');
}, 0);
Promise.resolve().then(() => {
console.log('promise');
queueMicrotask(() => {
console.log('nested microtask');
});
});
queueMicrotask(() => {
console.log('queued microtask');
});
console.log('B');Questions:
- Which output is synchronous?
- Which callbacks are processed during the first relevant microtask checkpoint?
- Does
nested microtaskwait for the timer? - Is a paint guaranteed before
timerruns?
Show the reasoning
The synchronous script prints:
A
BBefore the current task finishes, the script has also arranged:
- a later timer callback;
- a Promise reaction in browser microtask processing;
- a
queueMicrotask()callback.
At the checkpoint, the Promise reaction runs first because that microtask was queued before the explicit queueMicrotask() call in this example:
promiseThe Promise reaction then queues nested microtask. The checkpoint already contains queued microtask, so FIFO microtask processing continues:
queued microtask
nested microtaskOnly after the checkpoint becomes empty can later regular task work such as the timer proceed:
timerSo the output is:
A
B
promise
queued microtask
nested microtask
timernested microtask does not wait for the timer because it was added while the same microtask checkpoint was still draining.
This output ordering does not imply that the browser must paint before timer. Rendering opportunities and rendering updates are controlled separately by the browser.
Agent rule
In browser JavaScript, let the JavaScript for the current event-loop task finish, then reason about the microtask checkpoint before later regular task work. Promise reactions and
queueMicrotask()participate in browser microtask processing. Timers schedule later work and are not precise execution deadlines. Do not assume one universal FIFO task queue, a guaranteed paint between callbacks, or that browser scheduling rules transfer unchanged to Node.js.
When reviewing scheduling-sensitive browser code, verify against this checklist:
- Main thread duration: Will current task execution stay under 50ms (the threshold for a Long Task in DevTools)?
- Microtask checkpoint bounds: Do Promise chains or
queueMicrotask()calls have bounded recursion so the checkpoint can drain to empty? - Task source ordering assumptions: Does the code avoid assuming a fixed FIFO order between unrelated task sources (e.g., click handler vs timer)?
- Rendering assumptions: Does the code avoid assuming an automatic paint between consecutive tasks or microtasks?
- Animation frame timing: Is visual DOM/style update work scheduled via
requestAnimationFrame()rather than arbitrary timers? - Runtime environment boundary: Are browser-specific assumptions (rAF, DOM task sources) kept out of universal/Node.js logic?
Related concepts
- Promise settlement and chaining
queueMicrotask()- async waterfalls and dependency scheduling
- long tasks and main-thread responsiveness
requestAnimationFrame()- timers and scheduling
- workers
- the Node.js event loop
These relationships become direct Atlas lesson links as the corresponding lessons are added.
Sources
This lesson was last verified on 2026-09-09 and is classified as evolving with a 180-day review target.
- WHATWG HTML Standard — Event loops — normative browser task queues, task sources, event-loop processing, Promise-job integration, microtasks, checkpoints, and rendering scheduling.
- WHATWG HTML Standard — Timers — normative
setTimeout()/setInterval()timing behavior and timer nesting rules. - WHATWG HTML Standard — Animation frames — normative
requestAnimationFrame()API and animation-frame callback processing. - ECMAScript 2026 — Jobs and Host Operations to Enqueue Jobs — language-level Jobs and host scheduling hooks such as
HostEnqueuePromiseJob(). - Node.js v26 — Timers — current Node timer and
setImmediate()behavior. - Node.js v26 —
process.nextTick()— Node-specific next-tick behavior and current Legacy guidance recommendingqueueMicrotask()for most userland deferral.
Async Waterfalls: Overlap Independent Work Without Breaking Dependencies
Reduce latency by overlapping independent asynchronous work without breaking real dependencies.
Promises: Resolution, Chaining, and FailureNew
Reason about Promise states, resolution, chaining, error recovery, adoption, combinators, and modern Promise APIs.