Browser internals — event loop, paint, perf
📖 Walk me through it — plain English
This lesson is about what your browser is actually doing while a web page runs — the stuff frameworks like React hide from you. JavaScript (the language that runs in the page) is single-threaded, which means it has exactly one worker doing one thing at a time. There is no second worker secretly helping. So the browser uses an event loop: a simple repeating cycle that says "grab the next job, finish it completely, do a little cleanup, maybe repaint the screen, then grab the next job." If any single job takes too long, the whole page freezes — buttons don't click, scrolling stutters. That frozen feeling is called jank.
Think of a single cashier at a store. There's one cashier (the single thread). Customers line up (jobs waiting to run). The cashier serves one customer fully before calling the next — they can't ring up two people at once. If one customer dumps 500 items on the belt (a long job), everyone behind them waits and the line looks frozen. Two special rules make this analogy match the browser: (1) right after finishing each customer, the cashier first clears a small "express" side-counter of quick tasks before calling the next person from the main line, and (2) only between customers does the cashier glance up and tidy the store display (repaint the screen). They never tidy mid-transaction.
Those two queues have names. A microtask is an express side-counter job — the most common one is a Promise callback (a "call me back when this finishes" handler). Microtasks are special: the browser drains all of them before doing anything else, including before repainting. A macrotask is a normal main-line job, like the function you pass to setTimeout (which schedules code to run "later"); it waits for a future trip through the loop. The catch: if your code keeps adding microtasks in a loop, the express counter never empties, so the browser never gets to repaint — the screen starves and freezes even though "tiny" tasks are running.
The other half of the lesson is the render pipeline — the fixed sequence the browser runs to turn your changes into pixels: JS → Style → Layout → Paint → Composite. Layout (also called reflow) means recalculating where every element sits and how big it is. Paint fills in colors and text. Composite just slides already-painted layers around. The lower in that list you trigger, the cheaper it is. Changing an element's width forces Layout (recompute everything) — expensive. Changing transform or opacity only touches Composite — the browser just nudges a finished layer — so animations using those stay smooth.
Layout thrash is the classic perf trap this lesson warns about. If you read a freshly-changed size from the page (like offsetHeight), then write a change, then read again, then write again, the browser is forced to redo Layout synchronously on every read so the number it hands you is correct. Read–write–read–write in a loop can mean dozens of full layouts. The fix is to batch: do all your reads first, then all your writes, so Layout runs once. The tool for timing writes to line up with the next repaint is requestAnimationFrame — it says "run this right before the next paint."
Why this matters in an interview: senior frontend rounds ask "why is this slow?" The strong answer connects the dots — a long job blocks the single thread (jank), runaway microtasks starve rendering, animating layout-triggering properties is expensive while transform/opacity are cheap, and interleaved DOM reads and writes cause repeated synchronous layout. Knowing this is what separates someone who just fills in a framework template from someone who understands the runtime underneath it.
Senior frontend rounds probe what happens under the framework. "Why is this slow?" / "What's a microtask?" / "Why does this layout thrash?" — the answers separate template-fillers from people who understand the runtime.
The vocabulary, defined once
Before anything else, here is every term this lesson leans on, in one place, in plain English. Read these once and the rest of the page reads smoothly.
- DOM (Document Object Model): the browser's in-memory tree of objects representing your page. Each HTML tag becomes a node;
document.querySelectorand friends read and change this tree. The DOM is structure, not pixels. - CSSOM (CSS Object Model): the parallel in-memory tree of all the style rules that apply to the page, computed from your stylesheets.
- Render tree: the DOM and CSSOM merged together, keeping only what is actually visible (an element with
display:noneis in the DOM but not the render tree). This is what the browser measures and draws. - Layout / reflow: computing the exact size and position (x, y, width, height) of every node in the render tree. "Layout" and "reflow" are the same thing — the second is the older name. It is expensive because moving one element can shift everything after it.
- Paint: filling in the actual pixels — colors, text, borders, shadows — into layers (bitmaps in memory). Sometimes called rasterization.
- Composite: assembling the painted layers in the right order and offsets onto the screen, usually on the GPU. The cheapest step, because nothing is recomputed or repainted — layers just get arranged.
- Repaint: re-running Paint (and composite) for something whose appearance changed but whose geometry did not — e.g. changing a background color. Cheaper than reflow because no positions are recalculated.
- Critical rendering path: the minimum sequence of steps — fetch HTML, build DOM, fetch and build CSSOM, run render-blocking JS, build render tree, layout, paint — needed before the user sees the first pixels. Shortening it is what "fast first paint" means.
- Event loop: the endless cycle that runs one task to completion, then drains microtasks, then maybe renders, then picks the next task. It is what makes single-threaded JavaScript feel responsive.
- Jank: visible stutter — dropped frames, frozen scroll — caused by a single job hogging the one thread so the browser cannot render on time.
From HTML to pixels — a concrete walkthrough
Say the browser receives this tiny document over the network. Let us trace, step by step, how those bytes become colored dots on the screen — this is the critical rendering path.
<!DOCTYPE html>
<html>
<head><style> p { color: red; } </style></head>
<body>
<p>Hello</p>
</body>
</html>
- 1. Parse HTML → build the DOM. The browser reads the tags left to right and constructs the DOM tree:
html → head → style, andhtml → body → p → "Hello". At this moment it knows the structure but nothing about size, position, or color. - 2. Parse CSS → build the CSSOM. The
<style>rule becomes a CSSOM entry: "everyphascolor: red." Stylesheets are render-blocking — the browser will not paint until the CSSOM is ready, because painting with the wrong colors and re-doing it would flash ugly unstyled content. - 3. Build the render tree. DOM + CSSOM are merged into the render tree of visible nodes, each tagged with its computed styles. The
<p>node now carries "color: red." - 4. Layout (reflow). The browser computes geometry: the
<p>is a block, so it spans the full content width; the text "Hello" gets a measured width and height at the current font; the box is positioned at the top of the body. Output is a box for every node with exact x/y/width/height. - 5. Paint. The browser fills pixels into layers — the red glyphs of "Hello," any background, borders. Result is bitmaps in memory, not yet on screen.
- 6. Composite. The painted layers are handed to the GPU and assembled onto the screen in the correct stacking order. Now the user finally sees red "Hello." This first visible frame is the first paint.
The key mental model: each step depends on the one before, and the later the step, the cheaper it is to re-run. If you later change the text color, the browser can skip layout and just repaint. If you change only a transform, it can skip both layout and paint and just composite. That hierarchy is the whole secret to smooth UIs.
Why it matters: "smooth" usually means hitting 60 frames per second, which leaves the browser about 16 milliseconds per frame to run your JS, do layout, paint, and composite. Blow that budget and a frame is dropped — that is jank. Knowing which pipeline steps your code triggers tells you, before you ever profile, whether an animation can fit in 16 ms.
The event loop — an ordering example, traced
The event loop is the single most-tested idea here, so let us make it mechanical. The loop holds two queues. The macrotask queue holds whole "tasks" — the script you load, a setTimeout callback, a click handler. The microtask queue holds the small follow-up jobs — resolved Promise callbacks (.then/await continuations) and queueMicrotask. One turn of the loop is: (a) take exactly one macrotask and run it to completion; (b) drain the entire microtask queue, including any microtasks those microtasks add; (c) if it is time, render (style → layout → paint → composite); (d) go back to (a).
Here is the canonical example. The top-level script is itself the first macrotask, and synchronous lines inside it run immediately, top to bottom, before the loop ever looks at a queue.
console.log(1);
Promise.resolve().then(() => console.log(2));
setTimeout(() => console.log(3), 0);
console.log(4);
Trace it like the browser would:
console.log(1)is synchronous — it runs now. Output so far: 1.Promise.resolve().then(...)does not run the callback now; the promise is already resolved, so its callback is queued as a microtask. Nothing printed yet.setTimeout(..., 0)does not run now either; even with a 0 ms delay it queues a macrotask for a future turn of the loop. Nothing printed yet.console.log(4)is synchronous — runs now. Output so far: 1, 4.- The top-level script (the current macrotask) is finished. Now the loop drains all microtasks: the queued
.thenruns, printing 2. Microtask queue is now empty. - The loop may render here, then takes the next macrotask: the
setTimeoutcallback runs, printing 3.
Final output: 1 4 2 3. The one rule that explains it all: microtasks always run before the next macrotask (and before rendering), even when that macrotask was scheduled with a 0 ms timer.
The starvation trap: because the loop drains every microtask — including ones added during the drain — before it is allowed to render, a function that keeps re-scheduling itself as a microtask will run forever and the page will never repaint. function loop(){ Promise.resolve().then(loop); } loop(); freezes the tab. The same logic as a setTimeout (a macrotask) would not starve rendering, because each iteration is a separate task with a render opportunity between them. This is exactly why "split heavy work into macrotasks" keeps a page responsive.
JS is single-threaded. Loop: pull next task → run to completion → drain ALL microtasks → maybe render → repeat. Long task = jank. Break it up.
Promise.then = microtask, runs before next render. setTimeout(0) = macrotask, runs in a future loop. Microtasks can starve render if scheduled in a loop.
JS → Style → Layout → Paint → Composite. Changing width triggers Layout. Changing transform/opacity only Composites — far cheaper.
Read DOM (offsetHeight) → write DOM → read again forces synchronous layout each time. Batch reads, then writes. Use requestAnimationFrame.
Debounce = wait for quiet (typing → fire after pause). Throttle = max once per N ms (scroll → fire periodically). Pick by whether you want "final" or "regular."
Move heavy work off the main thread. Communicate via postMessage. No DOM access in the worker.
First paint depends on the first stylesheet + render-blocking JS. defer / async on scripts, inline critical CSS for fast first paint.
localStorage = sync, ~5MB, blocks main thread. IndexedDB = async, large. sessionStorage = per-tab. Cookies = sent with every request (small, careful).
Unpacking the cards
The grid above is dense by design — here is each card expanded so nothing is left as jargon.
- Render pipeline, in cost order. The five steps are JS (your code mutates the DOM/styles) → Style (recompute which rules apply) → Layout (geometry) → Paint (pixels) → Composite (arrange layers). Which step a change triggers determines its cost. Changing
width,top,font-size, or adding/removing nodes forces Layout — the most expensive path, since geometry of later elements can change too. Changingcolororbackgroundskips Layout but forces Paint. Changing onlytransform(move/scale/rotate) oropacityskips both and goes straight to Composite, which the GPU handles cheaply — that is why those two are the go-to properties for 60fps animation. - Debounce vs throttle. Both limit how often a handler fires, but differently. Debounce waits for a pause: every new event resets a timer, and the handler fires only after events stop for N ms — ideal for "fire the search after the user stops typing." Throttle fires at most once per N ms no matter how many events arrive — ideal for scroll or resize where you want regular updates, not just the final one. Rule of thumb: debounce when you only care about the final state, throttle when you want a steady cadence.
- Web Workers. A worker is a real second thread the browser gives you, used to run CPU-heavy work (parsing, crunching numbers) off the main thread so the UI stays responsive. The catch: a worker has no access to the DOM — it cannot touch the page directly. You communicate by passing messages with
postMessage, and the data is copied (or transferred), not shared. Use it precisely when a job is too big to fit in a 16 ms frame and cannot be chopped into small macrotasks. - Critical rendering path optimizations. Because CSS and synchronous JS are render-blocking, two levers speed up first paint. On scripts,
deferdownloads the script in parallel but runs it after the HTML is parsed (preserving order), andasyncruns it as soon as it arrives (order not guaranteed) — both stop the script from blocking DOM construction. Inlining critical CSS (putting the styles needed for above-the-fold content directly in the HTML) lets the browser build the CSSOM without a separate round trip, so it can paint sooner. - Storage options.
localStorageis simple key/value, persists across sessions, but is synchronous (it blocks the main thread) and capped around 5 MB — fine for small flags, bad for large blobs.sessionStorageis the same API but scoped to one tab and cleared when the tab closes.IndexedDBis an asynchronous, transactional database for large structured data — use it when localStorage is too small or its blocking would jank. Cookies are tiny and automatically attached to every matching HTTP request, which makes them right for auth tokens the server needs but wrong for general client state (they bloat every request).
Layout thrash — the pitfall, shown
"Layout thrash" (also called forced synchronous layout) is the most common self-inflicted perf bug. It happens when you interleave DOM writes (changing something that affects geometry) with DOM reads (asking for a measured value like offsetHeight, offsetWidth, getBoundingClientRect(), or getComputedStyle). Normally the browser batches your writes and does one layout later, before the next paint. But the instant you read a geometric value, it must give you a correct, up-to-date number — so it is forced to run a full synchronous Layout right now to flush all pending writes. Do that inside a loop and you trigger one layout per iteration.
// BAD: read → write → read → write... forces layout every iteration
for (const el of boxes) {
const h = el.offsetHeight; // READ: forces synchronous layout
el.style.height = (h + 10) + 'px'; // WRITE: invalidates layout again
}
// GOOD: batch all reads, then all writes -> layout runs once
const heights = boxes.map(el => el.offsetHeight); // all READS first
boxes.forEach((el, i) => { // then all WRITES
el.style.height = (heights[i] + 10) + 'px';
});
The fix is the read/write batching shown above. For animation work, schedule the writes inside requestAnimationFrame(cb) — it runs cb once, right before the next paint, so your DOM changes land in a single frame instead of forcing extra layouts mid-task. The mental rule: measure everything you need, then mutate — never ping-pong between the two.
Putting it together for the "why is this slow?" question: a single long job blocks the one thread and drops frames (jank); runaway microtasks drain forever and starve rendering; animating layout-triggering properties (width, top) re-runs the whole pipeline while transform/opacity only composite; and interleaving DOM reads with writes forces repeated synchronous layout. Naming the exact mechanism — not just "it's slow" — is the senior-level answer.
Takeaway: JavaScript runs on one thread driven by the event loop — run a task, drain all microtasks, maybe render, repeat. Promise callbacks are microtasks (before the next render); setTimeout callbacks are macrotasks (a future turn). Turning a page change into pixels follows JS → Style → Layout → Paint → Composite, and the later the step you trigger, the cheaper it is — so animate transform/opacity, not width. Avoid layout thrash by batching reads then writes (and using requestAnimationFrame), keep individual tasks under the ~16 ms frame budget, and push truly heavy work to a Web Worker.
Go deeper (optional): the MDN guides on the JavaScript event loop and on render performance, and Google's web.dev "Rendering performance" series, walk through these pipelines with profiler traces if you want to see them in DevTools.