The runtime model every frontend engineer must internalize: the DOM, events and delegation, and the single-threaded event loop that explains async, promises, and why long tasks freeze the UI.
You wire up a button. On click, it filters a list of 50,000 rows in a tight loop and re-renders. In testing it felt fine. In production a user clicks it and the entire page locks up, the spinner stops spinning, hover states stick, other buttons do nothing, even the browser's own tab feels dead. For two whole seconds nothing responds. Then it snaps back to life as if nothing happened.
Nothing crashed. No error in the console. The page wasn't "slow" in the network sense, the work was already done locally. So what happened? The answer is the single most important thing a frontend engineer can understand: JavaScript in the browser runs on one thread, and while your code runs, nothing else can. Not animation, not clicks, not paint. Once you internalize the runtime model, async stops being magic and bugs like this become obvious.
Who this is for
Frontend engineers who can write JavaScript but get surprised by async ordering, frozen UIs, or memory leaks from event listeners. If you've ever wondered why a `setTimeout(fn, 0)` doesn't run *immediately*, or why a Promise resolves "before" a timer, this is for you. No prior knowledge of the event loop assumed.
One chef, one thread
JavaScript in the browser is a single-threaded runtime: one call stack, doing one thing at a time. "Async" doesn't mean parallel, it means "do this later, when I'm free."
The cleanest mental model is a kitchen with exactly one chef. The chef can only do one task at a time. They have two notebooks. The first is a regular to-do list (tasks): "chop onions," "plate dish 4." The second is a priority list (microtasks): small urgent notes the chef promises to clear *before* starting the next to-do item.
The single chefThe one JavaScript thread (the call stack)
The dish the chef is cooking right nowThe function currently on the call stack
Sous-chefs handling the oven & deliveriesWeb APIs (timers, fetch, DOM events), run off-thread
The regular to-do listThe task (macrotask) queue, clicks, timers
The urgent priority list, cleared firstThe microtask queue, promise callbacks
The chef checking lists when hands are freeThe event loop
The kitchen maps directly onto the JavaScript runtime.
The crucial rule: the chef finishes the current dish completely before looking at either list. They never abandon a half-plated dish to answer the door. That "run to completion" rule is exactly why a long task freezes everything, and why ordering between timers and promises is so precise.
The picture: how the event loop works
Here is the whole runtime in one diagram. Your synchronous code runs on the call stack. Anything async (a timer, a fetch, a click listener) is handed to Web APIs, which run outside the thread and, when done, drop a callback into a queue. The event loop is the part that, whenever the stack is empty, drains the microtask queue fully, then takes one macrotask, then repeats.
The browser JavaScript runtime: stack ↔ Web APIs → queues → event loop → back to the stack.
1
User clicks a button
The browser's event system (a Web API) registers the click and queues your handler as a macrotask. Your currently-running code, if any, is not interrupted.
2
The stack empties
Whatever was running finishes and returns. The call stack is now empty, the event loop's signal that it may pick up new work.
3
Microtasks drain first
Before touching the click, the loop empties the entire microtask queue, every pending promise `.then`/`await` continuation runs to completion (and any microtasks they schedule run too).
4
One macrotask runs
Now the loop takes exactly one task, your click handler, and pushes it onto the stack. It runs to completion, start to finish, uninterrupted.
5
Render gets a turn
Only after the handler returns and microtasks are drained can the browser repaint. Then the loop comes back for the next macrotask.
Ordering: sync vs microtask vs macrotask
This is the table to memorize. Given this code, console.log("A"); setTimeout(() => console.log("D"), 0); Promise.resolve().then(() => console.log("C")); console.log("B");, the output is A, B, C, D. Synchronous lines run first, then all microtasks (the promise), then the macrotask (the timer), even though the timer was scheduled with 0.
Why A, B, C, D and not A, B, D, C, three priority tiers, drained in order.
setTimeout(fn, 0) is not immediate
The `0` is a *minimum* delay, not a guarantee. The callback is a macrotask, so it waits behind every pending microtask and behind the browser's own work. A `Promise.resolve().then(...)` scheduled *after* it will still run *first*.
Events, delegation, and async in real code
Two patterns cover most real frontend work: event delegation (one listener on a parent instead of one per child, leaning on event bubbling) and `async`/`await` for network calls. Here both are in a small, complete example, a list where clicking any item fetches its detail.
list.js
javascript
// ONE listener on the container, not one per <li>.// Clicks on children bubble up to here, that's delegation.const list = document.querySelector("#items");
list.addEventListener("click", async (event) => {
// event.target is the actual clicked node; find the row it lives in.const item = event.target.closest("li[data-id]");
if (!item) return; // clicked the gap between items, ignore.
item.classList.add("loading");
try {
// await yields the thread back to the event loop while the// network request runs off-thread in a Web API.const res = awaitfetch(\`/api/items/\${item.dataset.id}\`);
if (!res.ok) thrownewError(\`HTTP \${res.status}\`);
const data = await res.json();
// DOM manipulation: build a node, then attach it.const detail = document.createElement("p");
detail.textContent = data.description;
item.appendChild(detail);
} catch (err) {
item.textContent = "Failed to load, tap to retry.";
console.error(err);
} finally {
item.classList.remove("loading");
}
});
Why delegation wins: it works for rows added after the listener was attached (no rebinding), it uses one listener instead of thousands, and there's nothing to clean up per row. The await lines are where the thread is *handed back*, between them the browser is free to paint and handle other clicks. That's cooperative async on a single thread.
Why long tasks block paint
Now the frozen page makes sense. Rendering, style, layout, paint, is also work the one thread must do, scheduled between macrotasks. The browser wants to repaint roughly every 16ms (60fps). But it can only do that when the call stack is empty. While your filter-50,000-rows handler runs, the stack is never empty, so the loop never gets a turn, so no paint, no clicks, no animation. The thread is busy and there is only one.
A task that runs longer than ~50ms is officially a "long task", long enough that users perceive jank. The fix is never "make it async" by sprinkling promises (microtasks run on the *same* thread and still block paint). The fix is to break the work up, yield to the event loop between chunks (e.g. await scheduler.yield() or chunk across setTimeout/requestIdleCallback), or move the heavy computation off-thread entirely with a Web Worker, which is a genuinely separate thread.
Pro tip
Quick test: if `await Promise.resolve()` in the middle of your loop does NOT unfreeze the UI, the work is CPU-bound and belongs in a Web Worker or needs to be chunked with a macrotask that lets paint in between. Microtasks alone won't save you.
Common mistakes that cost hours
Blocking the main thread. Heavy loops, giant JSON parses, or synchronous layout reads in a handler freeze everything. Chunk the work, defer it, or offload to a Web Worker.
Leaking listeners. Every addEventListener you never remove keeps its closure, and the DOM nodes it references, alive. In SPAs that re-mount components, this is the classic memory leak. Remove listeners on teardown, or use delegation so there's only one.
Not delegating. Attaching a listener to every item in a list is slower to set up, breaks for dynamically-added items, and multiplies cleanup. One listener on the parent + event.target.closest() is almost always better.
`await` inside a loop.for (const id of ids) { await fetch(id); } runs requests strictly one after another. If they're independent, fire them together with await Promise.all(ids.map(fetch)), same thread, but the waiting overlaps instead of stacking.
Takeaways
The whole article in seven lines
Browser JavaScript is **single-threaded**: one call stack, one thing at a time.
Async work is offloaded to **Web APIs**; their callbacks return via queues.
The **event loop** drains ALL microtasks, then takes ONE macrotask, then lets the page paint.
Order is **sync → microtasks (promises) → macrotasks (setTimeout, events)**.
`setTimeout(fn, 0)` is a macrotask, it waits behind every pending promise.
A handler runs **to completion**; while it runs, no paint, no clicks, that's why long tasks freeze the UI.
**Delegate** events to a parent, **clean up** listeners, and **chunk or offload** CPU-heavy work.
Where to go next
Once the runtime model clicks, the rest of frontend engineering layers cleanly on top. Two natural next reads: understanding what the browser does *after* your handler returns, and how to manage the data your handlers produce without re-introducing the chaos manual DOM updates create.
How the Browser Renders a Page, what happens in that paint slot the event loop hands the browser: style, layout, and compositing.
Follow the full Frontend Engineer path to see where the runtime, rendering, and frameworks fit together.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.