Skip to main content
JavaScript is single-threaded — at any given moment, only one piece of code can run. Yet in practice, JavaScript handles timers, network requests, user interactions, and file reads without freezing. The mechanism that makes this possible is the event loop, one of the most important concepts to internalise as a JavaScript developer.

The single-threaded constraint

The JavaScript engine has a single call stack. It processes one function call at a time, from top to bottom. If a function takes a long time to run — say, a heavy loop or a synchronous network request — nothing else can happen during that time. The browser cannot repaint, user events are ignored, and the page appears frozen. This is why JavaScript relies on asynchronous, non-blocking APIs. Instead of waiting for an operation to complete, you register a callback and let the runtime invoke it when the result is ready.

The components

Before tracing the event loop, it helps to name the pieces involved:
ComponentRole
Call stackExecutes code — one frame at a time
Web APIsBrowser-provided async capabilities (timers, fetch, DOM events, etc.)
Callback queue (task queue)Holds callbacks from completed Web API operations
Microtask queueHolds promise callbacks and queueMicrotask callbacks
Event loopMoves items from queues to the stack when the stack is empty

The event loop cycle

1

Execute all synchronous code

The engine runs the current script from top to bottom. Every function call is pushed onto the call stack and popped when it returns. Web API calls (like setTimeout or fetch) are handed off to the browser immediately and return right away — they do not block the stack.
2

Drain the microtask queue

Once the call stack is empty, the event loop checks the microtask queue first. It runs every microtask currently in the queue. If a microtask adds more microtasks, those are also run before moving on. The microtask queue is fully drained before any macro-tasks execute.
3

Run one macro-task from the callback queue

If the microtask queue is empty, the event loop takes the oldest macro-task from the callback queue (a setTimeout callback, a setInterval tick, a DOM event handler, etc.) and pushes it onto the call stack.
4

Drain the microtask queue again

After each macro-task completes, the microtask queue is drained again before the next macro-task is started.
5

Render (if in a browser)

The browser may perform a rendering update (layout, paint) between event loop iterations, typically targeting 60 frames per second. Long-running tasks that block the loop will delay repaints and cause visible jank.
6

Repeat

The loop continues indefinitely: pick one macro-task, drain microtasks, repeat.
The terms “macro-task” and “task” are used interchangeably. The HTML specification uses “task” — the “macro” prefix is an informal way to distinguish tasks in the task queue from microtasks.

A concrete example

console.log("1 — synchronous start");

setTimeout(function macroTask() {
  console.log("4 — setTimeout callback (macro-task)");
}, 0);

Promise.resolve()
  .then(function microtask1() {
    console.log("3 — first promise .then (microtask)");
  })
  .then(function microtask2() {
    console.log("3b — second promise .then (microtask)");
  });

console.log("2 — synchronous end");
Output:
1 — synchronous start
2 — synchronous end
3 — first promise .then (microtask)
3b — second promise .then (microtask)
4 — setTimeout callback (macro-task)
Here is what happens step by step:
  1. console.log("1 …") runs synchronously.
  2. setTimeout is called. The callback is handed to the browser’s timer API, which will enqueue it as a macro-task after ~0 ms.
  3. Promise.resolve().then(microtask1) creates a resolved promise and schedules microtask1 on the microtask queue immediately.
  4. console.log("2 …") runs synchronously.
  5. The call stack is now empty. The event loop checks the microtask queue: microtask1 runs.
  6. microtask1’s .then(microtask2) schedules microtask2 on the microtask queue.
  7. microtask2 runs (microtask queue is drained completely).
  8. The microtask queue is empty. The event loop picks the macro-task: macroTask runs, printing "4 …".

setTimeout delay is a minimum, not a guarantee

const start = Date.now();

setTimeout(function () {
  console.log("Ran after:", Date.now() - start, "ms");
}, 100);

// Simulate a long synchronous task
while (Date.now() - start < 500) {}
Even though the timeout is set to 100 ms, the callback cannot run until the while loop finishes blocking the call stack. The actual delay will be at least 500 ms. This illustrates why long synchronous operations (“blocking the event loop”) harm responsiveness.
Never perform heavy computation synchronously on the main thread in a browser. Break work into smaller chunks using setTimeout or requestAnimationFrame, or move it to a Web Worker.

Microtasks vs macro-tasks

The microtask queue exists to give certain callbacks higher priority. Promise callbacks (then, catch, finally) and queueMicrotask all post microtasks. The rationale is that promise continuations should run as soon as possible after the current synchronous code, before any unrelated I/O or timers.
setTimeout(() => console.log("macro-task"));

queueMicrotask(() => console.log("microtask A"));

Promise.resolve().then(() => {
  console.log("microtask B");
  queueMicrotask(() => console.log("microtask C"));
});
Output:
microtask A
microtask B
microtask C
macro-task
microtask C is enqueued from within a microtask, but it still runs before the macro-task because the microtask queue is fully drained after each task — including microtasks added during draining.
If you find yourself in a situation where you need to yield to the event loop (to let the browser paint, for example), setTimeout(fn, 0) is the classic technique. In modern environments, scheduler.postTask or requestAnimationFrame provide more precise control.

async/await and the event loop

async/await is syntactic sugar over promises. An await expression suspends the current async function and schedules its resumption as a microtask once the awaited promise settles.
async function fetchData() {
  console.log("A — before await");
  const result = await Promise.resolve("data");
  console.log("C — after await, result:", result);
}

fetchData();
console.log("B — after calling fetchData");
Output:
A — before await
B — after calling fetchData
C — after await, result: data
When await is hit, fetchData is suspended. Control returns to the caller. "B" is logged synchronously. Then the microtask queue runs and resumes fetchData after "B".

Summary

The event loop is the scheduler that allows a single-threaded language to handle concurrency. Synchronous code always runs first. Microtasks (promises) drain completely after each task. Macro-tasks (timers, I/O callbacks) run one at a time, with microtasks processed between each. Long synchronous operations block everything, so keeping the call stack free is the key to a responsive application.