How the Browser Event Loop Actually Works
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 9, 2026
TL;DR
In ordinary browser code, the JavaScript that is already running finishes first. After that work finishes, the browser runs queued microtasks such as fulfilled-Promise reactions and queueMicrotask() callbacks before it moves on to later regular task work such as a timer callback.
So setTimeout(fn, 0) means “make fn eligible for later timer work,” not “interrupt what is running now.” Rendering is controlled separately by the browser; a paint is not guaranteed between arbitrary callbacks.
This page describes browser scheduling. Node.js also runs JavaScript and Promise microtasks, but Node has its own event-loop rules and no browser rendering pipeline.
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
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.
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.
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
Runnable task-source work
Script
Empty
Timer
Empty
User interaction
Empty
Networking
Empty
Rendering
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.
Rendering-related work
Rendering state: Running selected work
requestAnimationFrame callbacks
Empty
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
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
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, ask:
- What task is executing now, and can its JavaScript keep the browser busy for too long?
- Which callbacks are microtasks, and can the current checkpoint keep producing more microtasks?
- Which later operations are regular tasks, and what ordering does their actual API/task source guarantee?
- Is the code assuming a paint or animation frame where the browser has not promised one?
- Is a timer being used as a correctness deadline instead of best-effort later scheduling?
- Is browser-specific scheduling knowledge being applied to Node.js or another runtime without checking that runtime's rules?
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.