The event loop, tasks and microtasks

How the call stack, the task queue and the microtask queue decide the order your code runs in.

  • Beginner
  • 10 min read
  • Updated 17 Oct 2026

What you will learn

  • Predict the output order of async code
  • Explain microtasks
  • Explain why long tasks freeze the page and how to break them up

Before you start

  • Promises
  • Comfortable with promises and async/await
  • Has used setTimeout

JavaScript runs your code on one thread, one piece at a time. The event loop is the mechanism that decides which piece runs next. Once you understand it, you can predict exactly when a then callback, an await or a setTimeout runs, and you can explain why a busy loop freezes the page.

The three parts

Three structures matter:

  • The call stack. The functions that are running right now. When a function is called it is pushed on; when it returns it is popped off. JavaScript only does one thing at a time because there is only one stack.
  • The task queue. Work waiting to start from an empty stack: a setTimeout callback, a click handler, a message from a worker, a network response. Browsers actually keep several task queues and choose between them, but you can think of "tasks" as one group.
  • The microtask queue. Small follow-up jobs that must run as soon as the current code finishes: promise reactions (then, catch, finally), the code after an await, and callbacks passed to queueMicrotask().

The rule

The event loop repeats this cycle:

  1. Take one task from a task queue and run it until the call stack is empty.
  2. Run every microtask in the microtask queue, including any new microtasks queued while doing so, until the queue is empty.
  3. If it is time for a new frame, the browser may update the rendering (this is when requestAnimationFrame callbacks, style, layout and paint happen).
  4. Go back to step 1.

Running your script for the first time counts as a task. So the practical rule for reading code is:

  1. All synchronous code runs first, top to bottom.
  2. Then all microtasks run.
  3. Then the next task runs, followed by its microtasks, and so on.

First example

JavaScript
console.log('1: script start');

setTimeout(() => {
  console.log('5: timeout');
}, 0);

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

queueMicrotask(() => {
  console.log('4: queueMicrotask');
});

console.log('2: script end');

// → 1: script start
// → 2: script end
// → 3: promise reaction
// → 4: queueMicrotask
// → 5: timeout

Step by step:

  1. console.log('1: ...') runs immediately.
  2. setTimeout(..., 0) hands the callback to the timer system. When the timer expires, the callback is put in the task queue. It cannot run until the current task (the whole script) is finished.
  3. Promise.resolve() is already fulfilled, so then queues its callback as a microtask straight away.
  4. queueMicrotask queues a second microtask, behind the first.
  5. console.log('2: ...') runs. The script ends and the stack is empty.
  6. The microtask queue is drained in order: 3, then 4.
  7. Only now can the next task, the timeout, run.

A delay of 0 does not mean "now". It means "as a new task, after everything already queued ahead of it, and after all microtasks". Browsers may also clamp very short nested timers to a minimum of a few milliseconds.

await is a microtask too

An async function runs synchronously until its first await. Everything after the await is resumed later as a microtask.

JavaScript
async function loadSettings() {
  console.log('B: inside loadSettings, before await');
  await null;
  console.log('D: after await');
}

console.log('A: before call');
loadSettings();
console.log('C: after call');

// → A: before call
// → B: inside loadSettings, before await
// → C: after call
// → D: after await

Calling loadSettings() runs line B at once, then pauses at await and returns a pending promise to the caller. The caller carries on with line C. When the script finishes, the microtask resumes the function and D is printed. Awaiting a value that is not a promise, such as null, still pauses until the next microtask checkpoint.

Chained then callbacks interleave

Each then callback queues the next one in its chain only when it finishes. Two independent chains therefore take turns:

JavaScript
Promise.resolve()
  .then(() => console.log('a1'))
  .then(() => console.log('a2'));

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

// → a1
// → b1
// → a2
// → b2

After the script, the microtask queue holds a1 and b1. Running a1 fulfils the promise returned by the first then, which queues a2 at the back of the queue, behind b1. Running b1 queues b2. Then a2 and b2 run.

You rarely need to rely on this interleaving in real code, and you should not. It is useful because it proves the rule: microtasks run in the order they were queued, one at a time.

Microtasks inside a task

Every task gets its own microtask checkpoint when it finishes. A promise created inside a timeout runs before the next timeout:

JavaScript
setTimeout(() => {
  console.log('timeout 1');
  Promise.resolve().then(() => console.log('microtask from timeout 1'));
}, 0);

setTimeout(() => {
  console.log('timeout 2');
}, 0);

// → timeout 1
// → microtask from timeout 1
// → timeout 2

Both timers are queued as separate tasks. The first task logs, queues a microtask and ends. Before the event loop picks up the second task, it drains the microtask queue. This order is the same in current browsers and in Node.js 20 and later.

Exercise: predict the order

Cover the answer and write down the output before reading on.

JavaScript
console.log('start');

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

async function work() {
  console.log('work: begin');
  await Promise.resolve();
  console.log('work: resumed');
}

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

work();

console.log('end');

Reasoning:

  1. Synchronous: start. The timeout is scheduled as a task. The then callback is queued as microtask 1. work() runs synchronously and logs work: begin, then awaits an already fulfilled promise, which queues its continuation as microtask 2. Then end.
  2. Microtasks in order: then, then work: resumed.
  3. Next task: timeout.
Plain text
start
work: begin
end
then
work: resumed
timeout

When you are unsure, label each callback "sync", "microtask" or "task" before predicting anything. Most ordering puzzles become simple once every line has a label.

Why long tasks freeze the page

Rendering and input handling only happen between tasks. While one task runs, the browser cannot paint, cannot respond to a click and cannot run any other callback.

HTML
<button type="button" id="crunch">Run heavy work</button>
<p id="status" role="status" aria-live="polite"></p>

<script type="module">
  const button = document.querySelector('#crunch');
  const status = document.querySelector('#status');

  button.addEventListener('click', () => {
    status.textContent = 'Working…';
    const end = Date.now() + 3000;
    while (Date.now() < end) {
      // Busy for three seconds. Nothing else can happen.
    }
    status.textContent = 'Done.';
  });
</script>

You never see "Working…". Both text changes happen inside the same task, and the browser only paints after the task ends, by which point the text already says "Done.". For three seconds the page ignores every click and key press.

Microtasks make this worse, not better. Because the loop drains the entire microtask queue before rendering, a microtask that keeps queuing another microtask blocks the page as completely as a while loop:

JavaScript
function forever() {
  queueMicrotask(forever); // Never do this: rendering never gets a turn.
}

Breaking up long work

Split a long job into chunks and give the event loop a turn between them by scheduling each chunk as a new task:

JavaScript
function yieldToEventLoop() {
  return new Promise((resolve) => setTimeout(resolve, 0));
}

async function processAll(items, handleItem) {
  let lastYield = performance.now();

  for (const item of items) {
    handleItem(item);

    if (performance.now() - lastYield > 50) {
      await yieldToEventLoop();
      lastYield = performance.now();
    }
  }
}

await yieldToEventLoop() resolves from a setTimeout, which is a task, so rendering and input can happen before the loop carries on. Awaiting Promise.resolve() would not help: that is only a microtask, and the browser would still not render in between.

The 50 millisecond threshold matches what browser tools treat as a "long task". Some browsers also offer scheduler.yield() for this purpose. It is not yet available everywhere, so feature-detect it and fall back to the setTimeout version. For truly heavy computation, move the work off the main thread into a Web Worker.

Node.js

Node.js has its own event loop, built on the libuv library, with several phases for timers, I/O and setImmediate. Promise microtasks follow the same rule as in the browser: they all run before Node moves on to the next callback. Node also has process.nextTick(), a queue that is processed before promise microtasks in most cases. The details of Node's phases belong to a Node.js course; for code that runs in both environments, the sync, microtask, task rule in this lesson is what you need.

Summary

  • One call stack means one piece of JavaScript runs at a time.
  • Tasks include timers, events and network callbacks. Microtasks include promise reactions, code after await and queueMicrotask() callbacks.
  • Order: all synchronous code, then every microtask, then the next task, then its microtasks.
  • setTimeout(fn, 0) runs fn as a later task, never immediately.
  • The browser renders only between tasks, so long tasks and endless microtasks freeze the page.
  • Break long work into chunks that yield with a task, or move it to a Web Worker.

Further reading