The HTML event loop processing model
Learn the HTML event loop's task choice, microtask checkpoint, rendering opportunity, pausing rules, agents, workers, and host hooks.
- 01Trace one round of the loopSay which task runs, which microtasks run after it, and when the browser may add a task to draw the page.
- 02Tell the parts apartExplain the difference between task sources, agents, the page's loop, a worker's loop, and the hooks that connect promises to the browser.
- 03Predict the orderPredict the order of script, promise, and timer output without guessing how long anything takes.
The browser's fixed round
The browser runs your JavaScript one piece at a time. Each piece of work waits in a line until its turn. The event loop is the routine that picks the next piece and runs it. It goes round and round, and it follows the same steps every time.
A few words first:
- The host is the program that runs JavaScript. In this lesson, the host is the browser.
- A task is one normal piece of work, such as a click handler or a timer callback.
- A task source is the kind of work a task comes from, such as clicks, timers, or network replies.
- A microtask is a small job that must run soon, right after the current piece of work. A promise's
.then()callback is a microtask. - Rendering means drawing the page on the screen.
The rule that matters most: on one event loop, only one piece of JavaScript runs at a time. Two callbacks never run at the same moment.
alert() box is like the cashier stopping until someone presses OK.- In real life: A customer from one line
- In JavaScript: One task from a task source
- In real life: Everyone in the express lane
- In JavaScript: The microtask checkpoint
- In real life: Update the price board
- In JavaScript: Queue a drawing task when it is time
- In real life: A cashier waiting for OK
- In JavaScript: A paused alert dialog
The code below uses setTimeout(callback, 0). It asks the browser to run the callback later as a timer task, after a wait of 0 milliseconds. Promise.resolve().then(callback) makes a promise that is already done and runs the callback as a microtask.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("script");- Line 1 puts a timer task in a line for later. It does not run yet.
- Line 2 puts the promise callback in the microtask line. It does not run yet either.
- Line 3 runs right now, so
scriptprints first.
The script ends. The microtasks run next, so promise prints second. The timer task comes in a later round, so timer prints last.
In one normal round, the event loop picks at most one task that is ready and runs it. Then it runs every waiting microtask. This step is called the microtask checkpoint. When it is time to draw the page, the browser adds a drawing task to a line, and a later round runs it. The browser decides which line goes next.
So, for any callback, ask three questions:
- What work is running right now?
- Which line does the later work wait in?
- When do the waiting microtasks run next?
These questions work for a promise, a click, a timer, and a message from a worker.
Event loops, agents, and windows
The HTML standard is the document that says how browsers must behave. It uses the word agent for one place where JavaScript runs, one thing at a time.
- Each agent has exactly one event loop.
- One agent can hold several pages that can reach each other's code directly.
- A browser tab belongs to a window agent. Pages from the same site that are linked together, such as a page and a pop-up it opened, can share that agent and its loop.
console.log("start task");
console.log("finish task");Line 1 starts this task. Line 2 finishes it. Only then can this loop pick another task. Nothing else on the same loop can jump in between the two lines. This makes everyday page code easier to follow.
A worker is JavaScript that runs beside the page, in the background. A worker is not a second callback inside the page's loop. It has its own agent and its own event loop. The page and the worker talk by sending messages. Each message waits as a later task on the other side.
An agent is an idea from the standard. It does not promise one thread in the operating system. A browser may arrange its loops in different ways. For your code, the fact that matters is this: a callback on one event loop always finishes its normal code before another callback on that loop starts.
Each page also has a global object, the window object that holds names like document and setTimeout. The standard uses it to say which page owns a timer, a promise, or a page element. Two tabs from the same site do not always share one loop. It depends on how the browser groups them.
Task queues and task sources
A queue is a line of waiting work. A task queue is a line of waiting tasks. Each task comes from a task source, such as user input, timers, or the network. The standard keeps these sources apart. That way the browser can choose sensibly, instead of putting every callback in one long first-come, first-served line.
The code below uses queueMicrotask(callback). It puts the callback straight into the microtask queue, with no promise needed.
console.log("script");
queueMicrotask(() => console.log("microtask"));
setTimeout(() => console.log("timer"), 0);- Line 1 prints during the current script task.
- Line 2 puts a callback in the microtask queue.
- Line 3 puts a timer task in the timer line.
The output is script, microtask, timer.
Don't guess a rule like "timers always beat clicks" from one run. A ready timer and a click come from different sources. The browser chooses between them by its own rules and what is happening at that moment.
The standard calls each task queue a set, not a strict line. The browser picks a queue, then takes the first task in it that is ready to run. A browser may also put several sources in one queue. This keeps the page quick to respond. Work from the same source still runs in the order it was added.
A task source tells you what kind of work made the task. It does not give your code a fixed priority. Clicks and key presses, network replies, page changes, moving to a new page, and drawing all need different choices from the browser.
Choosing the next task
Each round, the loop picks one task that is ready. It does not run every task at once. The small model below makes that choice visible. Three tasks wait:
refresh, a timer task, added first.click, a user input task, added second.reply, a network task, added third.
Change the rule and watch which task is chosen. The microtask checkpoint still follows the chosen task either way.
const queues = {
input: ["click"],
timer: ["refresh"],
network: ["reply"],
};
const task = pickNextTask(tasks, rule);
runTask(task, microtasks);
runMicrotaskCheckpoint(microtasks);
maybeRender(now);Picked task: refresh
Microtasks that ran: promise cleanup
Drawing: queue rendering task
The model picked refresh. Then it ran these microtasks: promise cleanup. Drawing: queue rendering task.
refresh (a timer, queued first), click (user input), and reply (network). The rule decides which one runs this round.The "User input first" rule is only for this lesson. Browsers have no such button. It shows why task sources exist, and why your code must not count on timers always beating clicks, or clicks always beating timers.
With "Oldest task first", refresh was added first, so it wins. With "User input first", click wins, even though it came later. In both cases, the chosen task finishes before the model runs its microtasks. That part never changes.
Real browsers pick a queue in their own way, then take the first ready task in it. So write code that works whichever task goes first. When the order matters, make it clear in the code: use a promise or send a message. Don't rely on lucky timing.
The microtask checkpoint
The microtask checkpoint runs the microtask queue until it is empty. Promise callbacks wait in this queue. That is why they run before the loop picks a later timer task.
The code below is a small model of the checkpoint. It uses an array as the queue:
queue.shift()takes the first item out of the array.queue.push(item)adds an item to the end.while (queue.length)repeats as long as the array is not empty.
const microtasks = ["first promise"];
function runMicrotaskCheckpoint(queue) {
const output = [];
while (queue.length) {
const job = queue.shift();
output.push(job);
if (job === "first promise") queue.push("nested promise");
}
return output;
}
console.log(runMicrotaskCheckpoint(microtasks).join(","));- Line 1 starts the queue with one job.
- Line 6 takes the next job out of the queue.
- Line 8 adds a new job,
nested promise, when the first job runs. - Line 5 checks the queue again, finds the new job, and runs it too.
- Line 13 prints
first promise,nested promise.
first promise adds nested promise to the queue. The checkpoint runs it too, because it only stops when the queue is empty. Only then can the next task run.A replay of a real lesson function. It copies the rule of the HTML microtask checkpoint: keep going until the queue is empty.
script
function runMicrotaskCheckpoint(queue) { const output = []; while (queue.length) { const job = queue.shift(); output.push(job); if (job === "first promise") queue.push("nested promise"); } return output;} console.log(runMicrotaskCheckpoint(microtasks).join(","));Keep microtasks short. A chain that keeps adding more microtasks holds up tasks, clicks, and drawing the page.
"Until empty" includes jobs added while the queue is being emptied. So a promise callback can add another promise callback, and both finish before a ready timer. This is not a special promise rule. It comes from the way the checkpoint repeats.
After the queue is empty, the browser also does some tidying up. For example, it reports promises that failed with no .catch() to handle them. Don't use microtasks as a general to-do list for big jobs. A long chain can freeze the page, even though no single callback runs for long.
Updating what you see
The browser does not draw the page after every callback. Instead, from time to time it gets a rendering opportunity: a good moment to draw the page. When that happens, it adds a task to the rendering task source, the line for drawing work. A later round of the loop picks that task like any other task, and runs a microtask checkpoint after it.
The step-through below uses a made-up clock, to keep the model simple. It runs one normal task and the checkpoint. Then it says whether the browser would add a drawing task. It does not measure a real screen update.
A replay of a small model of the loop, not a debugger inside a real browser.
script
{ source: "timer", label: "timer", queuedAt: 0 }, { source: "input", label: "click", queuedAt: 1, microtasks: ["save cart"] },];const microtasks = ["promise cleanup"]; const task = pickNextTask(tasks, "oldest");runTask(task, microtasks);runMicrotaskCheckpoint(microtasks);const rendering = maybeRender(16);console.log(task.label, rendering);The model says queue rendering task at time 16. It does not decide when that drawing task gets picked. That happens in a later round.
The table lists the steps a drawing task can include. Read each row from left to right: the step, why it is there, and what it means for your code.
| Step | Why it is there | What it means for your code |
|---|---|---|
| Pick the pages to update. | Pages that are hidden or not ready to show are skipped. | Changing the page does not mean the new picture is on screen yet. |
| Handle resizing, scrolling, media queries, and animations. | These changes are handled together in one update. | Don't expect each callback to make its own new picture. |
Run requestAnimationFrame callbacks. | Code that asked to run just before drawing runs now, before styles and layout. | The next lesson covers the exact timing. |
| Work out styles and layout. | The browser works out what to draw and where. | Keep each task short, so this step gets a chance to run. |
| Draw the new picture. | The screen shows the page as it is now. | This happens only when the browser chooses; no callback can force it. |
The browser skips pages that are hidden, pages that are not ready to be shown yet, and pages that have no rendering opportunity. It can also skip an update when nothing visible has changed. So changing a page element means "the page's data changed". It does not mean "the new picture is already on the screen".
When the browser does update a page, it has several jobs, in this order:
- Handle resizing and scrolling.
- Check media queries (CSS rules that depend on screen size).
- Move animations forward.
- Run
requestAnimationFramecallbacks (code that asks to run just before drawing). - Work out styles and the layout (where everything goes).
- Tell observers that watch for size or visibility changes.
- Record paint timing, and draw the new picture on screen.
The next lesson, Rendering & the event loop, covers these steps in detail. This lesson only shows where drawing fits in the loop.
Window and worker event loops
A page's event loop is called a window loop. It adds drawing tasks for the page it owns. A worker's event loop is different:
- It can receive messages.
- It can run promise callbacks and other microtasks.
- It never draws the page.
One special case: a worker can draw on its own canvas. An OffscreenCanvas is a drawing surface that the page can hand over to a worker. A worker that supports this can run requestAnimationFrame callbacks and draw on that canvas.
In a worker, self is the worker's own global object. Setting self.onmessage says which function runs when a message arrives.
// worker.js
self.onmessage = (event) => {
console.log("worker received", event.data);
};Line 2 sets the message handler. It runs when the worker's loop picks a message task. Line 3 prints the data that came with the message. The worker can answer with postMessage. The page then handles that answer as another later task.
Because they are kept apart, the page and the worker don't share variables by default. Send data in messages. Use shared memory only in the special cases that need it.
The worker still has tasks and a microtask queue. A promise callback in the worker runs on the worker's loop. A message sent back to the page becomes later work for the page's loop. Neither side can stop the other side's callback in the middle.
Keep the basic rule: the page is drawn only by the window loop that owns it.
Pausing and spinning the loop
Some browser features pause the loop. While an alert() box is open, normal tasks and microtasks stop and wait. A synchronous XMLHttpRequest (an old way to ask a server for data, which waits for the answer before the next line runs) has the same problem. Avoid it in page code, so the page keeps responding.
console.log("before dialog");
alert("The event loop is paused until this closes.");
console.log("after dialog");- Line 1 prints
before dialog. - Line 2 opens the box and waits until it is closed. The lesson sandbox may close it for you.
- Line 3 prints
after dialog.
While the box is open, no other waiting work runs.
The standard also describes spinning the event loop. Only a few special browser steps use it, when they need to wait in the middle of a task. It is part of how the browser works inside. Don't use it as a pattern for your own code.
Pausing and spinning are different:
- Pausing: the current task waits. The loop runs no other tasks, and the running code stops at that line.
- Spinning: the browser sets the current task aside, runs a microtask checkpoint, and lets some chosen later work run. When the special step is done, the old task carries on.
Both are old tools that the browser uses inside. In page code, use features that don't block, and let the loop keep going. A synchronous request or an alert() box stops every normal line of work.
How promises reach the browser's queue
Two standards work together here:
- ECMAScript is the standard for the JavaScript language. It defines promises.
- The HTML standard defines the browser's queues.
ECMAScript does not say where promise callbacks wait. It asks the host. The questions it asks are called host hooks. In browsers, the hook HostEnqueuePromiseJob puts each promise callback in the HTML microtask queue.
Promise.resolve("order").then((value) => {
console.log("promise job", value);
});
console.log("script");- Line 1 makes a promise that is already done with the value
"order", and sets a callback for it. - Line 2 prints the value later, when the microtask checkpoint runs the callback.
- Line 4 prints now, so
scriptcomes first. Thenpromise job orderfollows.
There are a few other hooks:
HostPromiseRejectionTrackertells the browser about failed promises that nothing handles.HostEnqueueTimeoutJobadds timeout work forAtomics.waitAsync, a tool for waiting on shared memory.
See Promise internals for the promise side.
Other hooks remember which page a callback came from, so it runs in the right place later. Some add general clean-up jobs. Your code never calls these hooks. They explain how JavaScript promises reach the browser's queues, without the language standard having to know about browsers.
To see the same queue from the language's side, read Microtask queue internals.
Practical browser code
Use this model to explain what your page does. Don't use it to force an order the browser never promises. A good click handler does three things:
- It changes the page's data right away.
- It can add a small promise callback for a follow-up step that must happen soon.
- It lets the browser draw the page later.
The table below compares four words that are easy to mix up. Each row gives the word, what it means, and an example.
| Word | What it means | Example |
|---|---|---|
| Task | One normal piece of work that the loop picks from a task source. | A timer callback or a click handler. |
| Microtask | A small job that runs in the microtask checkpoint, before the loop picks another task. | A promise's .then() callback or a queueMicrotask callback. |
| Rendering opportunity | A good moment for the browser to add a task that draws the page. | Working out styles and layout, then drawing the new picture, when needed. |
| Paused loop | The loop waits and runs no other work. | An alert() box, or a synchronous XMLHttpRequest. |
- A click handler
- A
setTimeoutcallback whose wait is over - A promise's
.then()callback - A
queueMicrotaskcallback - A possible update that draws the page
- An
alert()box waiting to be closed
Put each item where it belongs in the loop: a task, a microtask, or another step.
To go over the basics again, see the event loop, microtasks, timers, and the main thread.
Common misconceptions
- "All callbacks for later wait in one queue." Each task source has its own line, and microtasks have a separate queue.
- "A timer of 0 runs right away." It becomes a task that can run in a later round.
- "The browser draws the page after every callback." It draws only when it gets a rendering opportunity, and only if something needs drawing.
- "Workers draw the page in the background." Workers never draw the page. A worker that supports it can draw only on its own
OffscreenCanvas. - "Promises keep running while an alert box is open." Pausing stops all normal work on that loop until the box closes.
Practice exercises
Type the output in order, separated by commas.
setTimeout(() => console.log("timer"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("script");The output is script, promise, timer. The script runs now, then the microtask checkpoint runs the promise callback, then a later round runs the timer task.
Type the output.
const microtasks = ["first promise"];
function runMicrotaskCheckpoint(queue) {
const output = [];
while (queue.length) {
const job = queue.shift();
output.push(job);
if (job === "first promise") queue.push("nested promise");
}
return output;
}
console.log(runMicrotaskCheckpoint(microtasks).join(","));It prints first promise,nested promise. Both jobs run in the same checkpoint.
Which task does the model choose with the "User input first" rule?
The click task wins under the model's "User input first" rule.
Does a worker's event loop draw the page?
No. A worker's event loop has no step for drawing the page.
What happens to other waiting work while an alert() box is open?
Nothing else runs on that loop while the alert box is open.
A click handler updates a shopping cart. A tiny follow-up step must run right after the handler, before any other task. What kind of queue fits?
Use a microtask, for example with queueMicrotask. It runs right after the click handler finishes, before any later task.
Check your understanding
Predict the output from the queues. Then tell apart what the standard promises and what a browser may choose.
Question 1 of 6What does the first example print?
Read the code, then predictsetTimeout(() => console.log("timer"), 0); Promise.resolve().then(() => console.log("promise")); console.log("script");Choose an answer to see the explanation.
Question 2 of 6In this model, how many normal tasks does one round of the loop pick?
Choose an answer to see the explanation.
Question 3 of 6A microtask adds another microtask while it runs. What must the microtask checkpoint do?
Choose an answer to see the explanation.
Question 4 of 6Which event loop draws the page?
Choose an answer to see the explanation.
Question 5 of 6What does
HostEnqueuePromiseJobdo for JavaScript promises?Choose an answer to see the explanation.
Question 6 of 6What is safe to say about drawing the page after a task?
Choose an answer to see the explanation.
Key takeaways
- Each round picks one task. Then the microtask checkpoint runs microtasks until the queue is empty.
- Task sources and microtasks are different things. Each task source has its own line; microtasks have one separate queue.
- A rendering opportunity adds a drawing task for the page. Workers never draw the page.
- An
alert()box and synchronous requests pause all normal work. - Promise callbacks reach the browser's microtask queue through host hooks.
Remember the one-liner.
Each round runs one task and then every microtask; drawing the page is just another task that the browser picks when it chooses.
Coming next: Rendering & the event loop.