Articles

Understanding the JavaScript Event Loop

JavaScript is single-threaded, yet pages stay responsive. A clear tour of the call stack, task queue, and microtasks — with examples you can run.

Takina Takina · · Updated · 4 min read
Layered web-platform planes

The event loop is the scheduler that lets JavaScript — a single-threaded language — juggle timers, network responses, and user input without freezing. Exactly one thread runs your page’s code, and the event loop decides what that thread does next: run this task, drain the microtask queue, maybe paint, repeat. Once you can picture that cycle, “why did it run in that order?” stops being a mystery.

The call stack: one thing at a time

JavaScript executes functions on a call stack. Call a function and it’s pushed on; return and it’s popped off. The engine only runs whatever is on top, and nothing else happens until the stack is empty — two of your functions never run at the same time.

That sounds like a recipe for a frozen browser, and it would be, except that the slow things aren’t actually done by JavaScript.

Single-threaded, but not blocking

When you call fetch() or setTimeout(), the engine doesn’t sit and wait. Those are Web APIs provided by the browser — or by the server runtime; Node.js and Bun ship their own equivalents. The host handles the network request or the timer on its own threads, and when the work finishes, it queues your callback. Your one thread never waited; it gets handed the result whenever it’s free.

Where the callback gets queued is what makes ordering interesting, because there are two queues with different priorities.

Tasks vs. microtasks

  • The task queue (macrotasks) holds callbacks from setTimeout and setInterval, plus events like clicks and messages.
  • The microtask queue holds promise reactions (.then, .catch, .finally), queueMicrotask callbacks, and MutationObserver notifications.

The loop’s rule, in order:

  1. Run one task to completion.
  2. Drain the entire microtask queue — including microtasks queued by other microtasks.
  3. Give the browser a chance to render.
  4. Take the next task. Repeat.

Microtasks always win. Every pending promise callback runs before the next timer or click handler, no matter what delay the timer asked for.

The ordering example to memorize

console.log("script start");

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

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

console.log("script end");

The output, every time:

script start
script end
promise 1
promise 2
timeout

Walk it through. The whole script is itself a task, so the synchronous logs run first. setTimeout hands its callback to the timer API, which queues it as a task. The resolved promise queues its .then as a microtask. When the script finishes and the stack empties, the loop drains microtasks: promise 1, then promise 2 — queued by the first .then but still drained in the same pass. Only then does the next task run: timeout. A zero-millisecond timer never means “now”; it means “after the current task and all microtasks.”

Where async/await fits

async/await is syntax over promises, so it rides the microtask queue. Everything up to the first await runs synchronously; everything after it becomes a promise continuation:

async function load() {
  console.log("A");                        // synchronous
  const res = await fetch("/api/data");
  console.log("B");                        // microtask, once the response arrives
}

An await doesn’t block the thread — it suspends the function, frees the stack, and resumes as a microtask when the promise settles. Between A and B, the loop is free to handle clicks and paint frames.

queueMicrotask(fn) gives you that scheduling directly, without allocating a promise — useful when you need “after the current work, before anything else.” Use it sparingly: because the loop drains microtasks completely, a microtask that endlessly queues another will starve rendering outright, which a setTimeout chain never does. Frameworks rely on the well-behaved version of this trick — reactive systems like signals typically batch state changes and flush DOM updates in a single microtask, so ten changes cost one render.

What actually freezes a page?

The event loop has one hard limit: it cannot interrupt a running task. Rendering happens between tasks on the same thread, so a long synchronous task blocks event handlers and paints until it returns:

button.addEventListener("click", () => {
  const end = Date.now() + 3000;
  while (Date.now() < end) {}  // page is unresponsive for 3 seconds
});

While that loop spins, nothing gets through — no clicks, no input, no repaints. This is exactly what responsiveness metrics like INP in Core Web Vitals catch: the user interacts, but the next paint can’t happen until the task finishes. The fixes follow from the model — split big jobs into chunks and yield between them with a timer, or move CPU-heavy work off the thread entirely with a Web Worker.

A note on Node.js

Node has no rendering to schedule, but the core rules carry over: one thread, tasks, microtasks drained in between. Its loop (built on libuv) is organized into named phases — timers, I/O polling, setImmediate callbacks, close handlers — and adds process.nextTick, which runs even before promise microtasks. Those details matter for servers under heavy I/O, but the mental model transfers intact: synchronous code first, then microtasks, then the next callback.

The takeaway

One thread runs your JavaScript, and the event loop feeds it: one task, then every pending microtask, then a chance to render. Promise callbacks and await continuations are microtasks, so they always beat timers and events — setTimeout(fn, 0) means “next task,” not “now.” Nothing can preempt a running task, which makes long synchronous work the one true way to freeze a page; break it into chunks or move it to a worker. Learn the task/microtask split and async ordering becomes predictable instead of surprising.

Takina Takina · · 4 min read

Async/Await vs Promises in JavaScript

Async/await is syntactic sugar over Promises, not a different mechanism. How each looks in practice, and when to still reach for raw Promises.

#JavaScript #Web Development #Async
Takina Takina · · 4 min read

TypeScript Abstract Classes, Explained

Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.

#TypeScript #JavaScript #Web Development
Takina Takina · · 4 min read

What Is a Lockfile? Reproducible Dependency Installs

A lockfile records the exact dependency versions your package manager resolved, so every install — from your laptop to CI — reproduces the same tree.

#JavaScript #Web Development #Developer Tools