Technology  /  JavaScript

๐ŸŸจ JavaScript 15 guides ยท updated 2026

Closures, the event loop, promises, and modern ES syntax โ€” the language of the web explained the way it actually behaves at runtime.

The JavaScript Event Loop: Call Stack, Task Queues, and Microtasks Explained

JavaScript runs on one thread. One call stack, one statement at a time, no parallel execution in the core language. And yet it powers UIs juggling clicks, animations, and network calls simultaneously, and servers handling tens of thousands of concurrent connections.

The trick is the event loop โ€” an architecture where JS never waits for anything slow, but instead schedules work to happen when results arrive. Itโ€™s the single most important systems concept in the language: it explains promise ordering, setTimeout lies, frozen UIs, and every โ€œwhy did these logs print in that order?โ€ mystery. Itโ€™s also a guaranteed interview topic. Fifteen minutes here pays off for years.


The Cast: Stack, Runtime APIs, Queues, Loop

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Call stack โ”‚ โ”‚ Runtime APIs (not JS!) โ”‚
โ”‚ (JS executing โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ–ถ โ”‚ timers, fetch/network, โ”‚
โ”‚ right now) โ”‚ hand โ”‚ DOM events, file IO... โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ฒโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ off โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
โ”‚ โ”‚ done โ†’ enqueue callback
โ”‚ take next โ–ผ
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ Microtask queue (promises) โ† drained COMPLETELY first โ”‚
โ”‚ Task queue (timers, events, IO) โ† then ONE task โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
the EVENT LOOP moves work from queues โ†’ stack
only when the stack is EMPTY

The phrase to engrave: run to completion. Each callback runs fully before the next begins โ€” no interruption, no interleaving mid-function. This is why JS needs no locks for its own variables (contrast Javaโ€™s world): two pieces of JS never touch the same data at the same instant. The price: anything long-running on the stack blocks everything โ€” clicks, rendering, timers. A 2-second synchronous loop is a 2-second frozen page.


Walking Through the Famous Example

console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3");
// prints: 1, 3, 2 โ€” despite the 0ms delay

Step by step:

  1. console.log("1") โ€” runs on the stack. Prints.
  2. setTimeout(cb, 0) โ€” hands cb to the runtimeโ€™s timer system with delay 0, returns immediately. JS does not wait.
  3. console.log("3") โ€” stack continues. Prints.
  4. Script finishes; stack is empty. The timer (long since expired) has placed cb in the task queue.
  5. Event loop: stack empty โ†’ run cb. Prints โ€œ2โ€.

The revelation for most learners: setTimeout(fn, 0) doesnโ€™t mean โ€œnowโ€ โ€” it means โ€œas soon as the stack is empty and the queue reaches me.โ€ The delay is a minimum, not a schedule. If the stack is busy for 3 seconds, a 0ms timer fires after 3 seconds. Understanding this converts a whole category of timing bugs from mysterious to obvious.


Microtasks vs. Tasks: The Ordering Rule Interviews Love

There isnโ€™t one queue โ€” there are (at least) two, with strict priority:

The rule: after any piece of JS finishes, the engine drains the ENTIRE microtask queue โ€” including microtasks queued by microtasks โ€” before touching the next task. One task, then all microtasks, repeat.

The canonical puzzle:

console.log("start");
setTimeout(() => console.log("timeout"), 0); // task
Promise.resolve().then(() => console.log("promise")); // microtask
console.log("end");
// start, end, promise, timeout

Synchronous code first (start, end). Stack empties. Microtasks drain (promise). Only then the next task (timeout). Promises always beat timers from the same tick โ€” not because theyโ€™re โ€œfaster,โ€ but by queue priority.

One sharper edge worth knowing: because microtasks-queued-by-microtasks also run before the next task, a microtask loop can starve the task queue entirely:

function loop() { Promise.resolve().then(loop); }
loop(); // page frozen: rendering and timers never get a turn

A setTimeout version of the same loop would be fine โ€” tasks yield to rendering between iterations; microtasks donโ€™t. This is trivia until the day a recursive promise chain freezes your app, at which point itโ€™s the diagnosis.

(Node adds refinements โ€” phases, setImmediate, process.nextTick running even before promise microtasks โ€” but the browser model above is the 95% mental model that transfers.)


What This Means for Real Code

Never block the stack. The UI renders between tasks โ€” long synchronous work means no paints, no clicks, a dead page. Heavy computation belongs in a Web Worker (a genuinely separate thread with its own event loop, communicating by messages โ€” JSโ€™s answer to true parallelism), or chunked across tasks so the loop breathes between slices.

Async is contagious by design. A function that starts async work cannot return its result synchronously โ€” the result doesnโ€™t exist yet, and the stack wonโ€™t wait:

function getUser() {
let user;
fetch("/api/user").then((r) => r.json()).then((u) => { user = u; });
return user; // ALWAYS undefined โ€” the .then runs long after this return
}

This โ€” the most common async beginner bug โ€” is the event loop enforcing its nature: the fetch callback canโ€™t run until the current stack (including getUser and its caller) finishes. The resolution is embracing the flow: return the promise and let callers await it โ€” the subject of the promises and async/await guides, which stand entirely on this chapterโ€™s model.

Zero-delay timers are a scheduling tool. setTimeout(fn, 0) genuinely means โ€œrun this after the current work, yielding to rendering firstโ€ โ€” useful for deferring non-urgent work. queueMicrotask(fn) means โ€œafter the current stack, before anything else.โ€ Two precision instruments, now that you know the queues.

Ordering across async boundaries is by queue, not by source order. Two fetches complete in whichever order the network decides; their .thens enqueue in completion order. When sequence matters, chain โ€” donโ€™t assume.


The Interview Version

โ€œExplain the event loopโ€ answered in four sentences: JavaScript is single-threaded with one call stack; slow operations are delegated to runtime APIs which enqueue callbacks when done. The event loop runs queued callbacks one at a time whenever the stack is empty, each to completion. Promise reactions go to a microtask queue thatโ€™s fully drained before the next task โ€” which is why promises resolve before timers scheduled in the same tick. Consequence: never block the stack, and structure async code around scheduling rather than waiting.

Then expect the ordering puzzle (practice the start/end/promise/timeout walkthrough until narrating it is automatic) and possibly โ€œhow would you keep a heavy loop from freezing the UI?โ€ (workers, or chunking with setTimeout/requestIdleCallback).


Chunking: Keeping Long Work Off the Stack in Practice

Since โ€œnever block the stackโ€ is this chapterโ€™s prime directive, hereโ€™s what the fix actually looks like when you canโ€™t move work to a worker. The problem:

// freezes the page for the whole duration
for (const row of millionRows) process(row);

The classic chunked version โ€” do a slice, yield to the loop, continue:

async function processAll(rows, chunkSize = 1000) {
for (let i = 0; i < rows.length; i += chunkSize) {
rows.slice(i, i + chunkSize).forEach(process);
await new Promise((r) => setTimeout(r)); // yield: rendering & input get a turn
}
}

Each chunk is one task; between chunks the browser paints, handles clicks, and runs timers. The page stays alive at the cost of slightly longer total time โ€” almost always the right trade for UI work. The modern refinement is scheduler.yield() (where available), which does the same yield with better queue priority semantics, and requestIdleCallback for genuinely deferrable background work that should only use spare time.

Knowing when you need this: the browserโ€™s definition of a problematic โ€œlong taskโ€ is anything over 50ms on the main thread. DevToolsโ€™ Performance panel highlights them in red โ€” profile before and after chunking, and you can watch the single scary block dissolve into a comb of small tasks with paint slots between them.

Frequently Asked Questions

Is JavaScript really single-threaded if workers exist? The language semantics per realm are single-threaded; workers are separate realms with separate loops sharing (almost) no memory, communicating by message passing. Thatโ€™s a concurrency architecture โ€” actor-like โ€” rather than shared-memory threading; the โ€œno locks neededโ€ property inside each realm is preserved. (SharedArrayBuffer + Atomics pokes a controlled hole for advanced cases; youโ€™ll know if you need it.)

Does await block the thread? No โ€” await suspends the async function, returning control to the event loop; the rest of the function resumes as a microtask when the promise settles. The stack is never held hostage. โ€œAwait blocks the function, not the threadโ€ is the precise phrasing โ€” full story here.

Why did my setTimeout(fn, 100) fire after 400ms? Some combination of: a busy stack when it expired, browser throttling (background tabs clamp timers), and queue backlog. Timers promise โ€œnot before,โ€ never โ€œat.โ€

Where does rendering fit? Between tasks, at the browserโ€™s discretion (typically aligned to display refresh, ~16ms) โ€” after microtasks drain. Hence the two rules of jank: long tasks skip frames, and microtask storms delay paints. requestAnimationFrame callbacks run just before paint โ€” the right hook for animation work.

Is Nodeโ€™s event loop the same? Same concept, more structure: libuv phases (timers, poll, checkโ€ฆ), setImmediate (after poll), and process.nextTick (ahead of even microtasks โ€” use sparingly). Browser intuition transfers; look up the phase diagram when Node timing gets subtle.


A Debugging Field Guide

Symptoms โ†’ event-loop diagnoses, from real incident patterns:

Why This Design Won

A closing perspective worth carrying: the event loop wasnโ€™t JavaScript settling for less โ€” it was a bet that aged remarkably well. Shared-memory threading (Javaโ€™s model) buys parallelism at the price of locks, races, and deadlocks; the single-threaded loop trades away parallelism for correctness by default โ€” no data races possible within a realm โ€” and turns out to fit the webโ€™s workload (IO-bound, event-driven, latency-sensitive) almost perfectly. Nodeโ€™s entire success story is this architecture applied server-side: thousands of concurrent connections, each mostly waiting, multiplexed over one cheap thread. And the industry noticed โ€” Javaโ€™s own virtual threads and structured concurrency are, in a real sense, the JVM importing the ergonomics that event-loop platforms proved out. The modelโ€™s genuine weakness (CPU-bound work) got dedicated escape hatches (workers) rather than compromising the core. Understanding why the design works โ€” not just how โ€” is what lets you feel when youโ€™re fighting it: if your code is drowning in coordination, youโ€™re probably doing thread-work on a loop platform, or loop-work with threads.

Self-Check

// 1 โ€” full order?
console.log("a");
setTimeout(() => console.log("b"));
Promise.resolve().then(() => console.log("c")).then(() => console.log("d"));
console.log("e");
// 2 โ€” and this one?
setTimeout(() => console.log("t1"));
Promise.resolve().then(() => {
console.log("p1");
setTimeout(() => console.log("t2"));
Promise.resolve().then(() => console.log("p2"));
});
console.log("sync");

Answers: (1) a, e, c, d, b โ€” sync first; the chained .then is a microtask queued by a microtask, still draining before the timer task. (2) sync, p1, p2, t1, t2 โ€” sync; microtask p1 queues both a new task (t2, behind t1) and a microtask (p2, which drains immediately after p1); then tasks in order.

Both correct โ€” including why โ€” and you have the model that makes the entire async half of JavaScript predictable. Which is exactly where the series goes next: Promises, the abstraction built on these queues.