
In the previous article, we learned that the main thread lives inside the renderer process — and that it's the only thread allowed to touch the DOM. We also dropped a bomb: the main thread is single-threaded, so running JavaScript and rendering the page both queue up for the same worker.
That raises the real question this article answers: who decides what the main thread does next? And more practically — why does while(true){} freeze a page, while setTimeout(() => …, 0) and fetch(...).then(...) don't?
The answer to all of these is the event loop. "Event loop" is one of the most over-explained topics in front-end interviews, but most explanations miss the single most important point: rendering itself is also a job managed by the event loop. Once you see that, "why JavaScript blocks rendering" stops being a mystery and becomes obvious.
requestAnimationFrame, splitting long tasks, avoiding microtask starvationUnderstanding of the main thread, the renderer process, and why it's single-threaded
Suppose you write a button handler that does some heavy work:
button.addEventListener("click", () => {
// pretend this is a big computation
for (let i = 0; i < 1e9; i++) { /* ... */ }
});Click it, and the whole page freezes — you can't scroll, can't select text, hover states stop updating. But wrap the same work in a setTimeout, and the page somehow stays alive:
button.addEventListener("click", () => {
setTimeout(() => {
for (let i = 0; i < 1e9; i++) { /* ... */ }
}, 0);
});Both run the same heavy loop. Why does one feel like the page is dead and the other doesn't? To answer this, we need to understand what the main thread is doing while your code runs — and who's in charge of it.
From the previous article: the main thread runs your JavaScript, parses HTML/CSS, computes styles, does layout, and paints — all by itself. It is one thread.
Think of it as a single cashier at a store. Customers (tasks) line up, and the cashier serves them one at a time. While serving one customer, the cashier can't serve anyone else, no matter how long that customer takes.
That's the essence of "single-threaded": at any moment, the main thread is either running JS or doing rendering work — never both at once. The heavy loop occupies the cashier for a long time, so the rendering "customers" have to wait. That's the whole story of "blocking." The rest of this article is about the queue.
The event loop is the mechanism that decides which task the cashier (the main thread) serves next. Strip away the jargon and it's essentially this:
loop forever:
1. take ONE task from the task queue and run it
2. run ALL microtasks until the queue is empty
3. if it's time to render, render (rAF → style → layout → paint)
4. go back to step 1That's it. The event loop is not a thread, not a mysterious engine — it's this loop, running on the main thread, deciding what happens next.
(Reality is a little more nuanced — input events are also dispatched here, and the browser may skip rendering if nothing changed — but this model carries you through 95% of real situations.)
The loop references two queues. Understanding the difference is half the battle.
Tasks (also called "macro tasks") are the "big" units of work:
<script> blocksetTimeout / setInterval callbacksfetch response callbacksMicrotasks are the "small" units that must all finish before the next task starts:
Promise.then / .catch / .finally callbacksqueueMicrotask(...)MutationObserver callbacksThe rule that trips everyone up: after one task finishes, the loop drains ALL microtasks before moving on. Not one microtask — all of them. If a microtask keeps adding new microtasks, the loop never escapes, and the next task (and rendering) wait forever.
Let's trace the canonical example:
console.log(1);
setTimeout(() => console.log(2), 0);
new Promise((resolve) => {
console.log(3);
resolve();
}).then(() => console.log(4));
console.log(5);<script> is ONE task. It starts running.console.log(1) prints 1.setTimeout(..., 0) schedules its callback as a new task to run later.new Promise(...) runs its executor synchronously, printing 3; resolve() marks it fulfilled..then(() => console.log(4)) queues the callback as a microtask.console.log(5) prints 5.4.setTimeout callback → prints 2.Output: 1 3 5 4 2.
The classic confusion is "why is 4 before 2?" Because microtasks are drained before the next task — a task boundary always waits for the microtask queue to empty.
Here's what most event-loop explanations skip: rendering is not magic, and it's not parallel. It's step 3 of the same loop.
The browser doesn't re-render on every DOM change. It batches rendering and does it at specific points in the loop — roughly once per frame, aiming for 60 frames per second (~16.6ms per frame). A single render pass goes through its own pipeline:
requestAnimationFrame callbacks → style calculation → layout → paint → composite(We'll dissect each stage in the coming articles. For now, treat them as "the render pass.")
Two consequences follow, and they're the real answers to "why JavaScript blocks rendering":
So while(true){} freezes the page not because of anything exotic — it's just one task that never ends, so the loop never reaches step 3. And setTimeout helps not because it's "magically async" — it just moves the heavy work into a later, separate task, giving the loop a chance to render in between.
Putting it together, here's one "frame" of a busy page:
input event (click) → task: run your handler → drain microtasks
→ requestAnimationFrame callbacks → style → layout → paint → compositeYour click handler is a task. Your Promise.thens are microtasks. Your animation work belongs in requestAnimationFrame. Then the browser computes styles, lays out, paints, and composites — most of it on the same main thread (compositing is partly off-loaded to the compositor thread, but style/layout/paint are on the main thread).
This is why the three pillars you set out to understand — parsing, the main thread, and the event loop — are actually one story: the main thread is the worker, the event loop is its scheduler, and rendering (parse → style → layout → paint) is just another job on the schedule.
fetch: The Network Is Not on the Main ThreadThe older version of this article went deep on fetch, and there's one nuance worth keeping. When you call fetch(url), the actual network request is not done by the main thread — it's handed to the browser's network process (see the previous article). fetch returns a Promise immediately, and when the response arrives, resolving that promise queues your .then callback as a microtask.
So await fetch(...) doesn't block the main thread while bytes download — the main thread stays free to render. "Blocking" only happens when your own task or microtask callback runs heavy synchronous work.
Understanding the loop hands you a toolbox:
requestAnimationFrame, not setTimeout. rAF runs right before the render pass, so it's aligned with the frame. setTimeout fires at arbitrary times — it can run twice in one frame (wasted work) or skip frames (jank).Promise chain freezes the page just as effectively as while(true){}, because microtasks are drained before rendering.Predict the output, then verify.
Run the canonical example from section 3 in the console and confirm you get 1 3 5 4 2. Then reorder the statements mentally and predict again — the goal is to feel the task/microtask order, not memorize it.
Feel the difference between blocking and not.
Create two buttons:
// Blocks: freezes the page for about a second
a.onclick = () => {
const t = Date.now();
while (Date.now() - t < 1000) {}
};
// Doesn't block the same way: yields to the loop
b.onclick = () => {
setTimeout(() => {
const t = Date.now();
while (Date.now() - t < 1000) {}
}, 0);
};Click a, then immediately try to select text on the page during the freeze. Click b and do the same. Observe the difference.
Freeze a page with microtasks only.
Run this, then try to interact with the page:
function starve() {
queueMicrotask(starve);
}
starve();The page becomes unresponsive even though every "task" technically completes — this is microtask starvation delaying step 3 (render).
Watch frames drop in the Performance panel.
Open DevTools → Performance, record while a long task runs. Look for the long "Task" block and the gap in "Frames" — this is the render pass being delayed, visualized.
setTimeout, fetch) vs microtasks (Promise.then, queueMicrotask, MutationObserver). Microtasks are fully drained before the next task.So far we've described the main thread and its scheduler from a distance — but we haven't watched it actually work. What exactly happens when the browser receives a chunk of HTML? How does a string of text become the DOM tree your JavaScript manipulates?
That's the next stop: the HTML parser. We'll follow bytes from the network all the way to a DOM tree, and see why the parser is "incremental" (streams content), "tolerant" (fixes broken markup), and — crucially — why a single <script> tag can bring the entire parse to a halt.