Event loop: microtasks and macrotasks
JavaScript in the browser — and in Node.js — runs on top of an event loop. It’s the mechanism that decides when each piece of your code gets to run: the click handler, the setTimeout callback, the resolved promise, the next repaint.
You can write plenty of working code without ever thinking about it. But once you need to keep the interface responsive during heavy work, or you hit a bug where two callbacks fire in a surprising order, the event loop stops being trivia and becomes the thing you actually need to understand.
We’ll start with the model, then put it to work on real problems.
Event Loop
The core idea is small. There’s a loop that never ends. On each turn the engine checks whether there’s a task to run. If there is, it runs it. If there isn’t, it sleeps until one shows up.
Written out as an algorithm:
- While there are tasks:
- run them, oldest first.
- Sleep until a task appears, then jump back to step 1.
This matches what you feel when you sit on a web page. Nothing is happening most of the time — the engine is idle. It only wakes up when something gives it work: a script to run, an event to dispatch, a timer that’s due.
Some concrete tasks:
- An external
<script src="...">finishes downloading → the task is to run it. - The user moves the mouse → the task is to dispatch a
mousemoveevent and run its handlers. - A
setTimeouttimer reaches its deadline → the task is to run its callback. - …and so on.
Each of these gets added as a task, the engine handles it, then goes back to waiting (burning almost no CPU while idle).
If a task arrives while the engine is already busy with another one, it doesn’t get dropped and it doesn’t interrupt. It waits in line. Those waiting tasks form a queue, usually called the macrotask queue (the term comes from the V8 engine).
So while the engine chews through a script, the user might jiggle the mouse (queuing mousemove) and a setTimeout might come due (queuing its callback). Those line up behind the current work.
The queue drains first come, first served. When the engine finishes the script, it takes the mousemove handler next, then the setTimeout callback, and so on down the line.
Straightforward so far. Two consequences are worth pinning down, because they trip people up:
- Rendering never happens in the middle of a task. However long your task runs — 5 milliseconds or 5 seconds — the browser will not repaint until it returns. Every DOM change you make during a task lands on screen only after the task ends.
- A task that runs too long freezes everything else. While the engine is stuck in one task, it can’t dispatch clicks, can’t repaint, can’t do anything. After a while the browser gives up and offers to kill the page with a “Page Unresponsive” dialog. You hit this with genuinely heavy computation, or with the classic bug: an accidental infinite loop.
That’s the model. Now the useful part.
Use-case 1: splitting CPU-hungry tasks
Suppose you have a task that eats CPU.
Syntax highlighting is a good real example — it’s what colorizes the code blocks on this page. To do it, the code parses the source, builds a pile of colored elements, and inserts them into the document. For a large block of text, that’s a lot of work.
While the engine is buried in highlighting, it can’t touch the DOM for anything else, can’t process clicks, can’t repaint. The page stutters or briefly locks up. Not acceptable.
The fix is to chop the big task into small pieces. Highlight the first 100 lines, hand control back with a zero-delay setTimeout, highlight the next 100 lines on the next turn, and keep going.
To keep the demo simple, forget highlighting and use a function that adds up every number from 1 to a billion into a running total.
Run this and the engine locks up for a moment. On the server the pause is obvious; in the browser, try clicking other buttons while it runs — nothing responds until the tally finishes:
let total = 0;
let start = Date.now();
function tally() {
// do a heavy job
for (let k = 1; k <= 1e9; k++) {
total += k;
}
alert(total + " summed in " + (Date.now() - start) + 'ms');
}
tally();
The browser might even pop the “script takes too long” warning.
Now split it with nested setTimeout calls:
let n = 0;
let total = 0;
let start = Date.now();
function tally() {
// do a piece of the heavy job (*)
do {
n++;
total += n;
} while (n % 1e6 != 0);
if (n == 1e9) {
alert(total + " summed in " + (Date.now() - start) + 'ms');
} else {
setTimeout(tally); // schedule the new call (**)
}
}
tally();
The interface stays fully alive during the count.
Each run of tally does one slice of the work at (*), then re-books itself at (**) if there’s more to do:
- First run adds
n = 1 … 1000000to the total. - Second run adds
n = 1000001 … 2000000. - …and so on.
The key difference: between two tally runs, the engine returns to the event loop. If a side task — say an onclick — showed up while slice 1 was running, it’s sitting in the macrotask queue, and it runs after slice 1 finishes and before slice 2. Those gaps give the engine room to breathe and react to the user.
Here’s the surprising bit: both versions — sliced and not — take roughly the same total time to count. Splitting the work barely changes the wall-clock total; it just spreads it out so the page stays responsive.
We can tighten it up, though. Move the scheduling to the top of tally():
let n = 0;
let total = 0;
let start = Date.now();
function tally() {
// move the scheduling to the beginning
if (n < 1e9 - 1e6) {
setTimeout(tally); // schedule the new call
}
do {
n++;
total += n;
} while (n % 1e6 != 0);
if (n == 1e9) {
alert(total + " summed in " + (Date.now() - start) + 'ms');
}
}
tally();
Now, the moment we enter tally() and can already tell more work is coming, we book the next call before doing this slice’s work rather than after.
Run it and it finishes noticeably faster. Why?
Browsers clamp nested setTimeout calls to a minimum delay of about 4ms once you go five levels deep. Ask for 0 and you still wait roughly 4ms (sometimes more). That delay is measured from the moment you call setTimeout. Schedule the next slice at the start of the function instead of the end, and the 4ms clock starts ticking earlier — it overlaps with the current slice’s work instead of following it. Over thousands of slices, that overlap adds up.
The result: a CPU-heavy job broken into pieces that no longer blocks the interface, with almost no penalty to its total running time.
Use case 2: progress indication
Slicing a heavy task buys you a second thing in the browser: you can show progress while it runs.
Recall the rule — DOM changes are painted only after the running task finishes, no matter how long that is.
That rule is usually a gift. Your function can create dozens of elements, insert them one at a time, tweak their styles, and the user never sees the half-built mess in between. They see the finished result, all at once.
But it cuts the other way when you want an intermediate state, like a live progress readout. In this demo, the assignments to n are all inside one task, so nothing paints until it ends — you only ever see the final number:
<div id="progress"></div>
<script>
function tally() {
for (let n = 0; n < 1e6; n++) {
n++;
progress.innerHTML = n;
}
}
tally();
</script>
Split the loop into setTimeout slices and the browser gets a chance to paint between them. Each slice ends, the DOM is repainted with the current n, then the next slice runs:
<div id="progress"></div>
<script>
let n = 0;
function tally() {
// do a piece of the heavy job (*)
do {
n++;
progress.innerHTML = n;
} while (n % 1e3 != 0);
if (n < 1e7) {
setTimeout(tally);
}
}
tally();
</script>
Now the <div> ticks upward as it counts — a working progress bar.
Here it is live. Start the count, then hammer the second button while it runs: the bar animates and the button keeps responding, because the engine returns to the event loop between slices. Try shrinking the slice size in the code to see how it trades smoothness for total speed.
Use case 3: doing something after the event
Inside an event handler, you sometimes want to hold off on an action until the event has finished bubbling and every handler on every level has had its turn. Wrap that action in a zero-delay setTimeout and it runs as a fresh macrotask, after the current event is completely done.
The chapter Dispatching custom events has an example. A custom tool-selected event is dispatched inside a setTimeout so it fires after the original click has been fully handled:
toolbar.onclick = function() {
// ...
// create a custom event carrying the chosen tool
let customEvent = new CustomEvent("tool-selected", {
bubbles: true
});
// dispatch the custom event asynchronously
setTimeout(() => toolbar.dispatchEvent(customEvent));
};
Without the setTimeout, the tool-selected event would dispatch in the middle of the click handling — and any code reacting to tool-selected would run before the click was truly finished, which can produce confusing ordering.
Macrotasks and Microtasks
Everything so far has been about macrotasks. There’s a second, separate queue: microtasks, covered in the Microtasks chapter.
Microtasks are born entirely from your own code. The usual source is promises — running a .then / .catch / .finally handler is scheduled as a microtask. Because await is promise handling under a nicer syntax, it uses microtasks too. And there’s a direct API: queueMicrotask(func) queues func onto the microtask queue.
The rule that ties the two queues together:
Look at this ordering puzzle:
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
What order do the alerts appear in?
codefirst — it’s an ordinary synchronous statement, running as part of the current task.promisesecond — the.thenhandler goes onto the microtask queue, which drains as soon as the current synchronous code finishes.timeoutlast —setTimeoutschedules a macrotask, and macrotasks only run after the microtask queue is empty.
Run the puzzle yourself — this logs the same three lines into the page in the order they actually execute. No matter how you reorder the three statements in the code, code prints first, promise before timeout:
Fitting microtasks into the fuller picture, one full turn of the loop looks like this — script first, then the whole microtask queue, then rendering, then the next macrotask:
The whole microtask queue empties before any event handling, any rendering, or any other macrotask. That guarantee matters: nothing about the outside world shifts underneath your microtasks. The mouse coordinates don’t move, no fresh network data arrives, no repaint sneaks in between them. They run as one uninterrupted burst, on a frozen snapshot of the environment.
So if you want to run a function asynchronously — after the current code, but before the browser renders or handles the next event — queueMicrotask is the tool.
Here’s the progress-bar counter again, but scheduling with queueMicrotask instead of setTimeout. Because the microtask queue drains fully before any repaint, the <div> never shows intermediate values — it renders only at the very end, exactly like the plain synchronous version:
<div id="progress"></div>
<script>
let n = 0;
function tally() {
// do a piece of the heavy job (*)
do {
n++;
progress.innerHTML = n;
} while (n % 1e3 != 0);
if (n < 1e6) {
queueMicrotask(tally);
}
}
tally();
</script>
See the split for yourself. The exact same slicing loop drives both bars — only the scheduler differs. setTimeout yields to rendering, so the bar creeps up; queueMicrotask drains before any repaint, so its bar jumps straight to full even though it ran the identical number of slices:
Summary
A fuller version of the event loop algorithm (still simplified next to the HTML specification):
- Dequeue and run the oldest task from the macrotask queue (for example, “script”).
- Run all microtasks:
- While the microtask queue isn’t empty:
- Dequeue and run the oldest microtask.
- While the microtask queue isn’t empty:
- Render changes, if there are any.
- If the macrotask queue is empty, wait until a macrotask appears.
- Go back to step 1.
To schedule a new macrotask:
- Use a zero-delay
setTimeout(f).
Use that to break a big, compute-heavy job into pieces so the browser can react to input and paint progress between them. It also works inside event handlers to defer an action until the current event has fully bubbled and been handled.
To schedule a new microtask:
- Use
queueMicrotask(f). - Promise handlers (
.then/.catch/.finally, andawait) also go through the microtask queue.
Nothing — no UI update, no network event — runs between microtasks; they fire back-to-back. So reach for queueMicrotask when you want a function to run asynchronously but still within the current environment state.