# Browser Event Loop: How Tasks, Microtasks, and Rendering Are Scheduled (/docs/programming/async/how-the-browser-event-loop-works)



## TL;DR [#tldr]

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.

> 💡 &#x2A;*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 [#start-with-one-concrete-example]

Predict this output before learning any event-loop vocabulary:

```ts
console.log('A');

setTimeout(() => console.log('timer'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('B');
```

In a browser, the relevant output is:

```text
A
B
promise
timer
```

The useful first model is simple:

1. the current JavaScript keeps running, so `A` and `B` appear first;
2. the already-fulfilled Promise schedules its callback for the browser's microtask processing;
3. the timer callback is later regular event-loop work;
4. 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.

<TermBox term="Task">
  A **task** is one unit of regular browser event-loop work selected to run, such as executing a script, dispatching certain events, or running a timer callback.

  **Why it matters here:** the browser lets the JavaScript for the selected task run to completion before it starts unrelated regular task work.
</TermBox>

<TermBox term="Microtask">
  A **microtask** is short checkpoint work kept separate from regular browser task queues. In browsers, fulfilled-Promise reactions and callbacks queued with `queueMicrotask()` participate in microtask processing.

  **Why it matters here:** the Promise callback in the example becomes microtask work, so it is processed after the current task finishes and before later timer task work.
</TermBox>

## Run-to-completion [#run-to-completion]

<AtlasIllustration id="event-loop-architecture" />

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**.

```ts
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 [#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.

<TermBox term="Microtask checkpoint">
  A **microtask checkpoint** is a point where the browser repeatedly takes and runs queued microtasks until the microtask queue is empty.

  **Why it matters here:** Promise reactions commonly run during this checkpoint before the browser moves on to later regular task work. A microtask can also queue another microtask, and the newly queued work can run during the same checkpoint.
</TermBox>

<AtlasIllustration id="microtask-checkpoint-drain" />

A nested microtask shows the “until empty” rule:

```ts
console.log('script');

queueMicrotask(() => {
  console.log('microtask A');

  queueMicrotask(() => {
    console.log('microtask B');
  });
});
```

The relevant order is:

```text
script
microtask A
microtask B
```

`microtask 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:

```text
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 considered
```

## Promise reactions and `queueMicrotask()` [#promise-reactions-and-queuemicrotask]

An already-fulfilled Promise still does not call its `then()` handler inline:

```ts
console.log('before');

Promise.resolve().then(() => {
  console.log('promise reaction');
});

console.log('after');
```

Output:

```text
before
after
promise reaction
```

The 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:

```ts
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 [#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:

```ts
setTimeout(doWork, 0);
```

means approximately:

> Make `doWork` eligible 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 `doWork` now.

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 [#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.

<TermBox term="Task source">
  A **task source** is the specification category that produced a task. Timers, user interaction, networking, and other browser features use task sources so their required ordering relationships can be defined.

  **Why it matters here:** unrelated task sources do not automatically gain one universal cross-source FIFO order. Correct code should rely on the ordering guarantee of the actual API or source instead of imagining one global “macrotask queue.”
</TermBox>

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:

```text
one global macrotask queue
[ timer ][ click ][ network ][ message ][ ... ]

always run the globally oldest item
```

A safer teaching picture is:

```text
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 apply
```

The lanes are teaching labels, not a claim that a browser must implement exactly one physical queue per task source.

## Rendering opportunities and `requestAnimationFrame()` [#rendering-opportunities-and-requestanimationframe]

Rendering is where many event-loop diagrams become misleading. A common simplified story says:

```text
task -> all microtasks -> paint -> next task
```

That is **not a guaranteed per-task paint sequence**.

<TermBox term="Rendering opportunity">
  A **rendering opportunity** is a point where the browser may decide that rendering-related work should be processed for a window.

  **Why it matters here:** finishing a task and draining microtasks does not promise an immediate paint. The browser may skip unnecessary rendering, coalesce work, or run additional tasks before a rendering update.
</TermBox>

<AtlasIllustration id="browser-rendering-pipeline" />

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:

```ts
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 [#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:

1. **Promise reaction vs timer** — shows `A`, `B`, Promise reaction, then timer.
2. **A microtask queues another microtask** — shows that a checkpoint drains newly produced microtasks before becoming empty.
3. **A timer queues a Promise reaction** — shows the microtask checkpoint after the timer task before later task work continues.
4. **Rendering opportunity + `requestAnimationFrame()`** — places animation-frame work inside a simplified rendering update instead of a fake post-microtask queue.
5. **Bounded microtask starvation** — models a self-producing microtask chain for five iterations, then stops with a warning instead of freezing the Atlas page.
6. **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.

<EventLoopLab />

## Multiple task sources and scheduler choice [#multiple-task-sources-and-scheduler-choice]

Suppose a simplified browser state has both of these runnable:

```text
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 [#starvation-and-responsiveness]

Two different scheduling problems can keep later work from progressing.

### Long synchronous work [#long-synchronous-work]

```ts
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 [#a-microtask-chain-that-keeps-replenishing-itself]

<TermBox term="Microtask starvation">
  **Microtask starvation** happens when microtasks keep creating more microtasks so the current checkpoint cannot become empty for a long time.

  **Why it matters here:** later regular tasks and rendering-related work can be delayed even when each individual microtask is small.
</TermBox>

<AtlasIllustration id="main-thread-starvation" />

```ts
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 [#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.

<TermBox term="ECMAScript Job">
  An **ECMAScript Job** is a language-level unit of deferred work defined by the ECMAScript specification. Promise reactions are expressed using Jobs and host hooks.

  **Why it matters here:** JavaScript defines the Promise semantics, while the surrounding environment decides how those Jobs are integrated into its scheduling system.
</TermBox>

<TermBox term="Host">
  In ECMAScript terminology, the **host** is the environment that integrates the JavaScript language with platform behavior and APIs. A web browser is one host environment; Node.js is another.

  **Why it matters here:** Promise language semantics can be shared across environments while browser task sources, rendering, and Node-specific scheduling remain different.
</TermBox>

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 [#browser-versus-nodejs]

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:

1. Which behavior comes from ECMAScript itself—for example Promise resolution and Jobs?
2. 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-considerations]

### Production scenario: The "invisible" microtask UI freeze [#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:

```ts
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 `for` loop 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:
  ```ts
  // 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 [#exercise]

Predict the output of this browser example before reading the answer:

```ts
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:

1. Which output is synchronous?
2. Which callbacks are processed during the first relevant microtask checkpoint?
3. Does `nested microtask` wait for the timer?
4. Is a paint guaranteed before `timer` runs?

<details>
  <summary>
    Show the reasoning
  </summary>

  The synchronous script prints:

  ```text
  A
  B
  ```

  Before 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:

  ```text
  promise
  ```

  The Promise reaction then queues `nested microtask`. The checkpoint already contains `queued microtask`, so FIFO microtask processing continues:

  ```text
  queued microtask
  nested microtask
  ```

  Only after the checkpoint becomes empty can later regular task work such as the timer proceed:

  ```text
  timer
  ```

  So the output is:

  ```text
  A
  B
  promise
  queued microtask
  nested microtask
  timer
  ```

  `nested 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.
</details>

## Agent rule [#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 [#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 [#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](https://html.spec.whatwg.org/multipage/webappapis.html#event-loops) — normative browser task queues, task sources, event-loop processing, Promise-job integration, microtasks, checkpoints, and rendering scheduling.
* [WHATWG HTML Standard — Timers](https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timers) — normative `setTimeout()` / `setInterval()` timing behavior and timer nesting rules.
* [WHATWG HTML Standard — Animation frames](https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#animation-frames) — normative `requestAnimationFrame()` API and animation-frame callback processing.
* [ECMAScript 2026 — Jobs and Host Operations to Enqueue Jobs](https://tc39.es/ecma262/2026/multipage/executable-code-and-execution-contexts.html#sec-jobs-and-host-operations-to-enqueue-jobs) — language-level Jobs and host scheduling hooks such as `HostEnqueuePromiseJob()`.
* [Node.js v26 — Timers](https://nodejs.org/api/timers.html) — current Node timer and `setImmediate()` behavior.
* [Node.js v26 — `process.nextTick()`](https://nodejs.org/api/process.html#processnexttickcallback-args) — Node-specific next-tick behavior and current Legacy guidance recommending `queueMicrotask()` for most userland deferral.
