The JavaScript Event Loop

Understand how single-threaded JavaScript handles timers, events and promises: the call stack, task queue, microtask queue and rendering, with output-order puzzles.

Lesson 50 of 56Advanced16 min read

JavaScript in the browser runs on a single thread: it can only do one thing at a time. Yet pages handle clicks, timers, network responses and animations all at once without freezing. How? The answer is the event loop, the scheduling system that decides what runs next.

Understanding the event loop explains puzzles that confuse many developers. Why does setTimeout(fn, 0) not run immediately? Why do promise callbacks run before a zero-delay timeout? Why does a long loop freeze the whole page, buttons and all? And why does await let other code run in the middle of your function?

In this lesson you'll learn the parts of the system, the call stack, the task queue, the microtask queue and the rendering step, then work through output-order puzzles until you can predict them confidently. These ideas tie together what you learned about timers, promises and async/await, and they're a favourite topic in technical interviews.

The parts of the event loop

PartWhat it holds
Call stackThe functions running right now. JavaScript runs whatever is on top until the stack is empty.
Web APIsThe browser's own helpers that wait for things: timers, network requests, event listeners. They run outside your JavaScript.
Task queue (macrotasks)Callbacks ready to run: timer callbacks, event handlers, message events.
Microtask queueHigher-priority callbacks: promise .then/catch/finally, code after await, queueMicrotask, MutationObserver.
RenderingStyle, layout and paint, plus requestAnimationFrame callbacks.

When you call setTimeout(fn, 1000), the browser's timer waits the second, then puts fn in the task queue. It still has to wait its turn.

The loop, step by step

The event loop repeats this cycle over and over:

  1. Take the oldest task from the task queue and run it until the call stack is empty. (The first task is your script itself.)
  2. Run every microtask in the microtask queue, including any new microtasks added while doing so.
  3. If it's time for a new frame, run requestAnimationFrame callbacks and render the page.
  4. Go back to step 1.

The key consequences: nothing interrupts code that's already running, all microtasks run before the next task, and the page can only repaint between tasks.

Why promises beat setTimeout

JavaScript
console.log("1");
setTimeout(() => console.log("4"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("2");
// 1, 2, 3, 4

1 and 2 are part of the current task. The promise callback is a microtask, so it runs as soon as the script finishes. The timeout is a task, so it waits for the next turn of the loop. A zero-delay timeout means "as soon as possible after everything else queued", not "now".

Long tasks freeze the page

Because rendering and event handling only happen between tasks, a function that runs for two seconds blocks everything for two seconds: clicks don't respond, animations stop and the page can't repaint. Browsers report tasks longer than 50ms as long tasks, and Google's Interaction to Next Paint (INP) measurement penalises them.

Fixes include:

  • Splitting work into chunks and yielding between them with await scheduler.yield() (where supported) or await new Promise(r => setTimeout(r)).
  • Moving heavy computation to a Web Worker, which runs on another thread.
  • Doing less work: caching results, debouncing events, or processing only what's visible.

JavaScript Event Loop Syntax

Scheduling tools

setTimeout(fn, 0);              // task: after current task and all microtasks
queueMicrotask(fn);             // microtask: right after the current task
Promise.resolve().then(fn);     // microtask
requestAnimationFrame(fn);      // before the next paint
await null;                     // rest of the async function becomes a microtask
await new Promise(r => setTimeout(r)); // yield to tasks and rendering

JavaScript Event Loop Summary

  • JavaScript runs one thing at a time on a single thread; the event loop schedules what runs next.
  • The browser's Web APIs wait for timers, network and events, then queue callbacks.
  • After each task, every microtask (promise callbacks, code after await) runs before the next task.
  • That's why Promise.then runs before setTimeout(fn, 0).
  • Long tasks block clicks and rendering; split them up or move them to a Web Worker.

JavaScript Event Loop Examples

Each example runs in the page. Edit the code and press Run to see what changes.

Example 1

Predicting the order

Each message is numbered in the order it really appears. Try to explain why before you run it.

console.log("1: script start");

setTimeout(() => console.log("8: timeout"), 0);

Promise.resolve()
  .then(() => console.log("4: promise 1"))
  .then(() => console.log("7: promise 2"));

queueMicrotask(() => console.log("5: queueMicrotask"));

(async () => {
  console.log("2: async, before await");
  await null;
  console.log("6: after await");
})();

console.log("3: script end");
ConsoleEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Line 1Synchronous code runs first, in order.
  2. Line 3The timeout callback is a task; it waits for the next turn of the loop.
  3. Lines 5 to 7The first then is queued as a microtask now; the second only after the first runs.
  4. Line 9Another microtask, queued straight after the first then.
  5. Lines 11 to 15Code before await runs synchronously; the rest becomes a microtask.
  6. Line 17The last synchronous line. Then microtasks run in the order queued, and finally the timeout.
Example 2

A frozen page and the fix

Press "Freeze" and try clicking "Count" straight away. Then try "Chunked".

let n = 0;
const status = document.querySelector("#status");
document.querySelector("#count").addEventListener("click", (e) => {
  e.target.textContent = `Count: ${++n}`;
});

document.querySelector("#freeze").addEventListener("click", () => {
  status.textContent = "Working...";
  const end = Date.now() + 1000;
  while (Date.now() < end) {}
  status.textContent = "Done (the page was frozen, and 'Working...' never appeared)";
});

document.querySelector("#chunked").addEventListener("click", async () => {
  for (let i = 1; i <= 20; i++) {
    const end = Date.now() + 100;
    while (Date.now() < end) {}
    status.textContent = `Chunk ${i} of 20`;
    await new Promise((r) => setTimeout(r));
  }
  status.textContent = "Done, and the page stayed responsive";
});
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Line 8This text change can't be painted, because rendering waits for the task to finish.
  2. Lines 9 to 10A second of busy work in one task blocks clicks and painting.
  3. Lines 15 to 18The same total work in 100ms chunks.
  4. Line 19Yielding with a timeout lets clicks and repaints happen between chunks.
Example 3

Microtasks can starve the page

let ticks = 0;
function loopMicro() {
  if (++ticks < 5) queueMicrotask(loopMicro);
  console.log("microtask", ticks);
}

setTimeout(() => console.log("timeout finally runs after", ticks, "microtasks"), 0);
queueMicrotask(loopMicro);
console.log("sync code done");
ConsoleEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Line 3Each microtask schedules another one.
  2. Line 7The timeout is a task, so it must wait until the microtask queue is completely empty.
  3. Line 8If this chain never stopped, the timeout, clicks and rendering would never get a turn.

Common JavaScript Event Loop Mistakes

Expecting setTimeout(fn, 0) to run immediately

Don't
let ready = false;
setTimeout(() => (ready = true), 0);
console.log(ready); // false: the callback hasn't run yet
Do
setTimeout(() => {
  const ready = true;
  console.log(ready);
}, 0);

Long synchronous loops in handlers

Don't
button.onclick = () => {
  for (const row of hugeList) expensiveProcess(row); // freezes the page
};
Do
button.onclick = async () => {
  for (const [i, row] of hugeList.entries()) {
    expensiveProcess(row);
    if (i % 200 === 0) await new Promise(r => setTimeout(r));
  }
};

Showing a loading message before heavy sync work

Don't
status.textContent = "Loading..."; // never painted
heavySyncWork();
Do
status.textContent = "Loading...";
await new Promise(r => requestAnimationFrame(() => setTimeout(r)));
heavySyncWork();

JavaScript Event Loop in Real Projects

The event loop in practice

Browser performance tools show long tasks as red-flagged blocks in the Performance panel. React schedules rendering work in small chunks so typing stays responsive. Node.js runs on the same event loop idea for servers, handling thousands of connections on one thread. And output-order questions like "what does this log?" are classic JavaScript interview questions.

JavaScript Event Loop Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Schedule a microtask.

(() => console.log("microtask"));
console.log("sync");
Predict the outputmedium

What is logged?

setTimeout(() => console.log("t"), 0);
Promise.resolve().then(() => console.log("p"));
console.log("s");
Multiple choiceeasy

How many things can JavaScript's main thread do at once?

Multiple choicemedium

Which of these is a microtask?

Fix the bugmedium

order() should resolve with the array ["sync", "micro", "task"], recording in the order things actually run. The pushes are in the wrong kinds of callbacks. Fix it without changing the final array.

function order() {
  const log = [];
  return new Promise((resolve) => {
    Promise.resolve().then(() => log.push("task"));
    setTimeout(() => {
      log.push("micro");
      resolve(log);
    }, 0);
    log.push("sync");
  });
}
order().then((log) => console.log(log));
ConsoleEdit the code, then press Run or Ctrl+Enter
Checks
  • Records the real order

JavaScript Event Loop Quiz

5 questions to check what you learned.

  1. When does a setTimeout(fn, 0) callback run?

    • Immediately
    • After exactly 0 milliseconds, interrupting other code
    • After the current code and all queued microtasks have finished
    • Never
  2. Why can't the page repaint during a long loop?

    • Loops disable the GPU
    • Rendering only happens between tasks, and the loop is still running
    • The browser is downloading
    • CSS is paused
  3. What happens to the rest of an async function after await?

    • It runs immediately
    • It's discarded
    • It moves to a new thread
    • It's scheduled to continue once the awaited promise settles
  4. How long is a "long task" according to browsers?

    • More than 50ms
    • More than 5ms
    • More than 1 second
    • More than 10 seconds
  5. Why can an endless chain of microtasks freeze the page?

    • Microtasks use more memory
    • All microtasks must finish before the next task or render, so tasks and painting never get a turn
    • Microtasks block the network
    • They run on the GPU

JavaScript Event Loop Challenge

Process a big list without freezing

Write an async function processInChunks(items, size, fn) that:

  1. Calls fn(item) for every item, in order
  2. After every size items, yields to the event loop with await new Promise((r) => setTimeout(r))
  3. Resolves with the number of items processed
async function processInChunks(items, size, fn) {

}

const nums = Array.from({ length: 10 }, (_, i) => i);
processInChunks(nums, 3, (n) => console.log(n)).then((count) => console.log("done", count));
ConsoleEdit the code, then press Run or Ctrl+Enter
Checks
  • Processes every item in order
  • Yields between chunks
  • Returns a promise