← back to section

The article Async and event loop in TypeScript covers the basics: single thread, non-blocking I/O, task queue. This article covers browser specifics: macrotasks vs microtasks, rendering between ticks, and what happens when a task runs too long.

Call stack, task queue, and event loop

The browser runs JavaScript in a single thread. Everything currently executing lives in the call stack. When the stack is empty, the event loop picks the next task from the task queue.

console.log("1"); // synchronous — goes straight to the stack

setTimeout(() => console.log("3"), 0); // queued — runs after "2"

console.log("2"); // synchronous — goes to the stack

// Output: 1, 2, 3

setTimeout with a delay of 0 does not mean "immediately" — it means "no earlier than the stack is clear and the current tick has finished."

Macrotasks and microtasks

The event loop maintains two queues, and they are processed in different order.

Macrotasks (task queue): setTimeout, setInterval, DOM events, fetch callbacks, postMessage. After each macrotask, the browser may render.

Microtasks (microtask queue): Promise.then/catch/finally, queueMicrotask, MutationObserver. Microtasks run immediately after the current task, before the next macrotask and before rendering.

console.log("1");

setTimeout(() => console.log("4"), 0); // macrotask

Promise.resolve()
  .then(() => console.log("2"))  // microtask
  .then(() => console.log("3")); // another microtask

// Output: 1, 2, 3, 4

Walkthrough: "1" — synchronous. Then the stack is empty: the browser drains all microtasks ("2", "3"), and only then picks up the macrotask ("4").

Starvation: when microtasks starve the renderer

If a microtask keeps spawning new microtasks indefinitely, rendering never happens. The browser cannot insert a frame until the microtask queue is empty.

function flood() {
  Promise.resolve().then(flood); // new microtask from a microtask
}
flood(); // page freezes — render is blocked

queueMicrotask is not a tool for "defer a little later"; it is faster than setTimeout but yields nothing to the renderer.

Where the browser renders

The loop looks like this:

  1. Execute one macrotask.
  2. Drain all microtasks to the bottom.
  3. (Optionally) If a frame is due (~16 ms) — run requestAnimationFrame callbacks and repaint the page.
  4. Go back to step 1.

requestAnimationFrame fires before rendering but after the current tick's microtasks — this is the right place for animations because the code runs immediately before the frame is drawn.

requestAnimationFrame(() => {
  // change the DOM for animation here — no intermediate frame
  element.style.transform = `translateX(${x}px)`;
});

Long tasks block the UI

If a macrotask runs for more than ~50 ms, the browser cannot draw frames at 60 fps (16 ms per frame) — the page lags and clicks stop responding.

// Bad: blocks the thread for hundreds of milliseconds
function processHuge(items) {
  items.forEach(heavyWork);
}

Chunking with setTimeout is the standard technique. After each chunk the browser gets a chance to render a frame and process events.

function processInChunks(items, chunkSize = 100) {
  let i = 0;
  function step() {
    const end = Math.min(i + chunkSize, items.length);
    for (; i < end; i++) heavyWork(items[i]);
    if (i < items.length) setTimeout(step, 0); // yield to the loop
  }
  step();
}

The modern alternative is scheduler.yield() (Chrome 115+) or scheduler.postTask(): the scheduler decides when to yield based on priorities.

Practical implications for React

Update batching. React 18 automatically merges multiple setState calls into one render, even inside setTimeout or Promise.then. Before React 18, batching only worked inside synchronous event handlers.

// React 18 — both setStates produce a single re-render
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
}, 0);

Why setState is "asynchronous". setState does not update state immediately. The update is queued and applied after the current event handler finishes — that is batching. After the commit React updates the DOM, and the browser draws the next frame.

useEffect and microtasks. useEffect callbacks run after rendering, asynchronously (like a macrotask). useLayoutEffect runs synchronously after DOM mutation, before the frame is painted. That is exactly why useLayoutEffect is needed for DOM measurements: when it runs, the browser has not yet painted the frame.

Trap: what does the code print

console.log("A");

setTimeout(() => console.log("B"), 0);

Promise.resolve().then(() => {
  console.log("C");
  setTimeout(() => console.log("D"), 0);
});

console.log("E");

// Output: A, E, C, B, D

Walkthrough: A, E — synchronous. Stack empty → microtasks: C + registration of the second setTimeout. Then macrotasks in order: B, D.

In short

  • The browser executes JavaScript in a single thread: while the stack is busy there is no rendering, no events.
  • Event loop: one macrotask → all microtasks → (optionally) render a frame → next macrotask.
  • Microtasks (Promise.then, queueMicrotask) run before the next macrotask and before rendering.
  • An infinite microtask chain starves the renderer — the page freezes.
  • requestAnimationFrame fires before the frame is rendered — the right place for animations.
  • Tasks longer than ~50 ms block the UI; break them up with setTimeout(step, 0) or scheduler.yield().
  • React 18 batches setState even inside setTimeout; useLayoutEffect runs before the frame is rendered.
  • Async and event loop in TypeScript — the primer: single thread, Promise, async/await.
  • this: call context — why this is lost in setTimeout and addEventListener callbacks.
  • DOM events: bubbling, capturing, delegation — how events enter the task queue.
  • Rendering performance — how long tasks affect Core Web Vitals.