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:

  1. While there are tasks:
    • run them, oldest first.
  2. 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 mousemove event and run its handlers.
  • A setTimeout timer 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).

engine
← takes oldest
script
mousemove
setTimeout
↑ front (runs next)newest ↑
Tasks pile up in a FIFO queue. The engine pulls one from the front, runs it to completion, then pulls the next.

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:

  1. 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.
  2. 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:

  1. First run adds n = 1 … 1000000 to the total.
  2. Second run adds n = 1000001 … 2000000.
  3. …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.

one big task — click waits until the very end
tally 1e9
click
sliced — click slots into the first gap
slice 1
click
slice 2
slice 3
Splitting the work lets queued user events (a click, a repaint) run in the gaps between slices instead of waiting for the whole job.

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.

schedule at end: work, then wait 4ms, then next slice
slice work
~4ms wait
next slice
schedule at start: the 4ms clock runs during the slice work
slice work
next slice
(the ~4ms already elapsed alongside the work)
Scheduling early lets the ~4ms timer clamp overlap with the current slice's work instead of stacking after it.

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.

interactiveA sliced count that paints progress and stays responsive

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?

  1. code first — it’s an ordinary synchronous statement, running as part of the current task.
  2. promise second — the .then handler goes onto the microtask queue, which drains as soon as the current synchronous code finishes.
  3. timeout last — setTimeout schedules a macrotask, and macrotasks only run after the microtask queue is empty.
1
sync code runs
→ alert(“code”)
2
drain microtasks
→ alert(“promise”)
3
next macrotask
→ alert(“timeout”)
One script task runs the synchronous code, then the microtask queue drains completely, and only then does the next macrotask (the timeout) get a turn.

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:

interactiveSync, microtask and macrotask — the order they really run

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:

1 · run oldest macrotask (e.g. script)
2 · drain ALL microtasks
3 · render changes, if any
4 · wait for the next macrotask → back to 1
One iteration of the event loop, top to bottom. Microtasks always run to exhaustion before rendering or 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:

interactivesetTimeout animates the bar; queueMicrotask skips to the end

Summary

A fuller version of the event loop algorithm (still simplified next to the HTML specification):

  1. Dequeue and run the oldest task from the macrotask queue (for example, “script”).
  2. Run all microtasks:
    • While the microtask queue isn’t empty:
      • Dequeue and run the oldest microtask.
  3. Render changes, if there are any.
  4. If the macrotask queue is empty, wait until a macrotask appears.
  5. 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, and await) 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.