W3docs

JavaScript Event Loop, Microtasks, and Macrotasks

Learn how the JavaScript event loop works and how microtasks (Promises) and macrotasks (timers, events) are scheduled, with runnable examples.

JavaScript runs your code on a single thread: one thing happens at a time, top to bottom. Yet it can still fetch data, run timers, and respond to clicks without freezing. The mechanism that makes this possible is the event loop, and the work it schedules is split into two kinds of jobs: microtasks and macrotasks. This page explains what each one is, the order they run in, and the gotchas that trip people up — every example here is runnable so you can check the output yourself.

How the Event Loop Works

The event loop is the scheduler that decides what piece of code runs next. To picture it, you only need three parts:

  1. Call stack — where your code actually runs. Functions are pushed on when called and popped off when they return. JavaScript runs whatever is on the stack to completion before doing anything else; this is the run-to-completion rule.
  2. Heap — the memory where your objects live. Not directly involved in scheduling, but it's the third piece people expect to see named.
  3. Task queues — pending work waiting for the stack to be empty. There are two: the macrotask queue (timers, UI events, I/O) and the microtask queue (Promise callbacks and queueMicrotask).

One turn of the event loop looks like this:

  1. Run the current task on the stack until the stack is completely empty.
  2. Drain the entire microtask queue — including any microtasks added while draining.
  3. (In a browser) render any pending visual updates.
  4. Take one macrotask from the macrotask queue and run it, then go back to step 2.

The key asymmetry: after each macrotask the engine empties all microtasks, but it only ever takes one macrotask per loop turn. That single rule explains almost every ordering surprise you'll meet.

Here's the simplest possible demonstration, using setTimeout to schedule a macrotask:

javascript— editable

In this example:

  1. console.log('Start'); is executed first, printing "Start" to the console.
  2. setTimeout schedules a callback to run after at least 1000 milliseconds. It returns instantly and does not block the lines below it.
  3. console.log('End'); runs immediately, printing "End".
  4. Only after the synchronous script finishes (and the delay has elapsed) does the event loop pull the setTimeout callback off the macrotask queue and run it, printing "Timeout Callback".

The output is Start, End, then Timeout Callback — the timer callback waits even though it was written in the middle. The setTimeout callback is a macrotask: it runs only after the currently executing script and all pending microtasks are done. That's what keeps the page responsive — synchronous code never has to wait on a timer or network request.

Microtasks vs. Macrotasks

What are Macrotasks?

A macrotask (also called simply a "task") is a single, self-contained unit of work the engine picks up once per loop turn. Common sources are:

  • setTimeout / setInterval: timers that run a callback after a delay or repeatedly.
  • DOM events: a click, scroll, or input handler.
  • I/O: network responses, file reads, and similar.

The engine runs exactly one macrotask, then drains every microtask, then (in the browser) may render, before taking the next macrotask. So macrotasks never run back-to-back without the microtask queue being emptied in between.

What are Microtasks?

A microtask is a short job the engine wants to finish as soon as the current code unit ends — before yielding to the next macrotask or to rendering. They come from:

  • Promise callbacks: the functions passed to .then(), .catch(), and .finally(), plus the body of an async function after an await.
  • queueMicrotask(fn): a built-in that schedules a function directly onto the microtask queue.

The crucial difference: after the current task, the engine drains the whole microtask queue before doing anything else. If a microtask schedules another microtask, that new one also runs in the same drain — before the next macrotask gets a turn.

Real-World Code Examples

Example 1: A timer is a macrotask

Imagine you want to show a message after 2 seconds. The scheduling line runs now; the callback is parked in the macrotask queue until the delay passes and the stack is free.

javascript— editable

Explanation: setTimeout returns instantly, so both console.log lines outside it run first. The callback is a macrotask that runs only after the synchronous script finishes and the timer fires. In a browser you'd typically update the DOM inside the callback, e.g. document.getElementById('message').textContent = 'Hello there!';.

Example 2: A Promise callback is a microtask

A resolved Promise's .then() callback does not run inline — it's queued as a microtask and runs once the current synchronous code finishes.

javascript— editable

Explanation: The output is Before the promise, After the promise, then Promise resolved (microtask). Even though the Promise is already resolved, its .then() callback waits in the microtask queue until the synchronous code is done — then it runs before any timer would.

More About Priority of Micro and Macro Tasks

Microtasks always have higher priority than macrotasks. After the current script finishes, the engine drains every pending microtask before it touches a single macrotask — even a setTimeout(..., 0) that was scheduled first. Notice in the example below that the chained Promise 2, created inside a microtask, still runs before either timer, because the microtask queue is drained completely before the loop moves on.

javascript— editable

Expected output:

Start
End
Promise 1
Promise 2
Timeout 1
Timeout 2

This shows that microtasks run immediately after the synchronous code, even before timers scheduled for the same moment. The prioritization means Promise-based updates settle as soon as possible.

A Gotcha: Microtask Starvation

Because the engine drains the entire microtask queue before the next macrotask or a render, a microtask that keeps scheduling more microtasks can block everything else — timers never fire and the page can't repaint. This is called microtask starvation:

javascript— editable

The five microtasks all run before the setTimeout callback, even though the timer was scheduled first. In a real app, an unbounded version of this loop would freeze the UI. The fix is to break long-running work into macrotasks (e.g. setTimeout(..., 0)), which lets the event loop render and handle events between chunks.

When to Use Which

  • Reach for microtasks (Promises, queueMicrotask) when you want code to run as soon as the current operation finishes but still asynchronously — like reacting to data right after a fetch resolves.
  • Reach for macrotasks (setTimeout, splitting work across timers) when you deliberately want to yield to the browser so it can render or handle input before continuing — for example, chunking a heavy computation so the page stays responsive.

Conclusion

The event loop runs your synchronous code to completion, then drains all microtasks, then takes one macrotask, and repeats. Microtasks (Promise callbacks, queueMicrotask) always run before the next macrotask (timers, events, I/O). Internalizing that single rule lets you predict the exact order of any asynchronous code.

To go deeper, continue with Promises, Promise chaining, async/await, and the dedicated chapter on microtasks. For the timer APIs used here, see scheduling with setTimeout and setInterval.

Practice

Practice
In JavaScript, what happens when a promise resolves and there is a `.then()` handler attached?
In JavaScript, what happens when a promise resolves and there is a `.then()` handler attached?
Was this page helpful?