Performance
Making a page feel fast is a different skill than making code run fast.
The critical rendering path
What actually has to happen before a browser can paint a single
pixel: download the HTML, parse it into a DOM, download and
parse CSS into a CSSOM, combine the two into a
render tree (only the nodes that will actually be visible),
compute every element's exact size and position
(layout, also called reflow), then finally
paint pixels for each one. A <script> with
no defer/async blocks this whole pipeline
at the HTML-parsing step — the exact mechanism behind
the script-vs-module blocking
behavior from the very first chapter.
| Reflow (layout) | Repaint | |
|---|---|---|
| Triggered by | anything that changes size or position — width, font-size, adding/removing an element | anything that changes appearance only — color, background, visibility |
| Cost | expensive — can cascade to the whole subtree, sometimes the whole page | cheaper — no geometry to recompute |
| Cheapest of all | transform and opacity — these two can often skip layout AND paint entirely, handled straight on the compositor thread | |
el.offsetHeight (or getBoundingClientRect())
forces the browser to run layout right now if anything is
pending, instead of waiting for its natural time. Alternating writes
and reads of layout properties in a loop —
el.style.width = x; console.log(el.offsetHeight);,
repeated — forces a full synchronous reflow on every
iteration. This pattern has an actual name: layout
thrashing. The fix is always the same shape: batch every read
first, then batch every write.
Not blocking the main thread
requestAnimationFrame(fn) schedules fn to
run right before the browser's next paint — the correct place for
any animation logic, because it's synced to the actual screen
refresh instead of guessing at a delay like
setTimeout would.
requestIdleCallback(fn) is the opposite priority: run
fn only when the browser is otherwise idle, with time to
spare before the next frame — for genuinely low-priority work
(analytics batching, prefetching) that should never compete with
anything the user is actually looking at.
function animate() {
el.style.transform = "translateX(" + x + "px)";
x += 2;
if (x < 300) requestAnimationFrame(animate); // re-schedule for the NEXT frame
}
requestAnimationFrame(animate);
requestIdleCallback(() => {
sendAnalyticsBatch(); // only runs if the browser has spare time before the next frame
});
Debounce and throttle solve a
different problem — how often a handler runs at all — and
compose naturally with this: throttle a scroll handler down to a
sane rate, then do the actual DOM write inside
requestAnimationFrame so it lands at the right moment in
the render pipeline.
The metrics that actually get measured
| Metric | Measures |
|---|---|
| LCP — Largest Contentful Paint | how long until the biggest visible element (usually a hero image or heading) renders |
| INP — Interaction to Next Paint | how long the page takes to visibly respond to a click, tap, or keypress — replaced the older FID metric for exactly this reason: FID only measured the delay before a handler started running, INP measures the whole thing including how long the handler itself takes |
| CLS — Cumulative Layout Shift | how much visible content jumps around unexpectedly — an image with no reserved width/height popping in and shoving everything below it down is the classic cause |
A long task is any single chunk of main-thread JS running longer than 50ms without yielding — the main thread can't paint, or respond to input, until it's done, so a long task directly hurts both LCP and INP at once. Lighthouse is the tool that turns all of this into one number and a prioritized list of fixes; the metrics above are what it's actually measuring underneath that score.
Rendering less, later, or not yet
// Virtual list — render only the ~20 rows actually visible, not all 50,000
function VirtualList({ items, rowHeight, viewportHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const start = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight);
const visible = items.slice(start, start + visibleCount);
// render "visible" only, with top/bottom spacers sized to fill the scroll area
}
A virtual list keeps DOM node count roughly constant regardless of
data size — 50,000 rows and 50 rows cost the same, because only
what's actually in the viewport (plus a small buffer) is ever
mounted. loading="lazy" on an <img>
is the built-in, no-JS version of the same idea for images below the
fold. Prefetching is the opposite bet — load something
before it's needed, on a strong signal it's about to be
(hovering a link, an IntersectionObserver from
two chapters back firing near
the bottom of the page) — trading a little wasted bandwidth on guesses
that don't pan out for a page that already has the next thing ready.
Tree shaking and bundle size
Already covered: tree shaking
only works because ESM imports are static and analyzable — a
bundler can see the entire dependency graph and delete anything
provably unused. "Provably" is the load-bearing word: a module with
side effects at its top level (code that runs just from being
imported — registering something globally, patching a prototype)
can't be safely deleted even if nothing imports a name from it,
because deleting it would change behavior. package.json's
"sideEffects": false field is a library author's
explicit promise that none of their files do this, which is what
lets a bundler tree-shake it aggressively instead of playing it safe.
A bundle analyzer (a treemap of what's actually inside the shipped JS, sized by byte) is how "why is this bundle 400kb" stops being a guess — it routinely surfaces one unexpectedly heavy dependency, or an entire library imported for one small utility function that could have been hand-written in ten lines instead.
Opens in the editor — write it, run it, and check it against real tests.