Performance Optimisation from First Principles
Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.
Performance · Frontend · JavaScript · Browser — Pratik Rai ·
Core Web Vitals (LCP, INP, CLS), loading strategies (code splitting, tree shaking, resource hints, image optimization), runtime performance (layout thrashing, compositor animations, web workers, virtualization), and a structured debugging framework.
Performance work has a failure mode that shows up long before any technical mistake: optimising the thing that wasn't slow. What makes the difference is knowing which metric describes the problem, which tool measures it, and which fix actually moves it.
This covers Core Web Vitals, loading strategies, runtime performance, the network layer, and a method for diagnosing a slow page rather than guessing at it.
Google's three user-centric metrics. One important change: INP replaced FID in March 2024, and they measure meaningfully different things.
| Metric | Measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading — when the biggest visible element renders | ≤ 2.5s | 2.5–4s | > 4s |
| INP (Interaction to Next Paint) | Responsiveness — latency across all interactions | ≤ 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | Visual stability — how much content moves unexpectedly | ≤ 0.1 | 0.1–0.25 | > 0.25 |
The render time of the largest image or text block in the viewport. Usually a hero image, a large heading, or a banner.
It goes bad from slow server response, render-blocking CSS and JavaScript, large unoptimised images, client-side rendering that delays the main content, and — the classic own goal — lazy-loading the hero image, which delays the exact element the metric is measuring.
The fixes:
srcset.preload it so the fetch starts early rather than after CSS parsing discovers it.loading="lazy" on it.HTMLindex.html1<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" /> 2<img src="/hero.avif" fetchpriority="high" alt="..." />
The metric that replaced FID, and a much harder one to game. FID measured only the delay before the first interaction's handler began — which flattered pages that were sluggish everywhere except the first click. INP measures the full latency — input delay, processing time, and the next paint — across every interaction during the page's lifetime, then reports a near-worst-case value.
It goes bad when long-running JavaScript occupies the main thread while the user is trying to interact: heavy synchronous work in handlers, large re-renders, expensive computation triggered by a click.
The fixes:
setTimeout, requestIdleCallback, or scheduler.yield().useTransition or useDeferredValue to mark updates as non-urgent so interactions stay responsive.JSfile.js1// Yield periodically so a long loop doesn't freeze interaction 2async function processInChunks(items) { 3 for (let i = 0; i < items.length; i++) { 4 doWork(items[i]); 5 if (i % 100 === 0) await new Promise((r) => setTimeout(r, 0)); 6 } 7}
How much visible content moves unexpectedly. This is the metric behind the universally hated experience of reaching for a button just as an ad loads and pushes it out from under your finger.
It goes bad from images and videos without dimensions, ads and embeds injected without reserved space, web fonts causing reflow as they swap in, and content inserted above what the user is already reading.
The fixes:
width and height, or aspect-ratio, on images and media so space is reserved before the file loads.font-display: optional or swap, and preload fonts, to limit text reflow.CSSstyles.css1img { aspect-ratio: 16 / 9; width: 100%; height: auto; }
Worth keeping straight. Lab data is a synthetic run under controlled conditions — Lighthouse. Field data is what real users on real devices and real networks experienced — CrUX and RUM.
They diverge, often dramatically, because your laptop on office wifi is not a mid-range Android phone on a congested mobile network. Both are useful, but Core Web Vitals scoring uses field data, so that's the one that counts.
Ship only what the user needs for the current view.
Route-based splitting gives each route its own chunk. It's the highest-impact and easiest form, and usually the first thing to do.
Component-based splitting lazy-loads heavy components — modals, charts, rich text editors — at the moment they're needed.
JSXcomponent.jsx1const Dashboard = lazy(() => import('./Dashboard')); 2 3<Suspense fallback={<Spinner />}> 4 <Dashboard /> 5</Suspense>
Dead-code elimination at build time: the bundler drops exports nothing imports. It depends on ES modules, because static import/export can be analysed without running the code — CommonJS require is dynamic and cannot.
It also depends on how you import. import { debounce } from 'lodash-es' lets the bundler keep one function; importing the whole library defeats it.
| Hint | Purpose | Use for |
|---|---|---|
preload | Fetch a resource needed by this page, high priority | LCP image, critical font, key script |
prefetch | Fetch something likely needed by the next navigation, low priority | Next-page bundle on hover or idle |
preconnect | Warm the connection — DNS, TCP, TLS — to a third-party origin | API domain, CDN, font host |
dns-prefetch | DNS lookup only; lighter than preconnect | Many third-party origins |
HTMLindex.html1<link rel="preconnect" href="https://api.example.com" /> 2<link rel="preload" as="font" href="/inter.woff2" type="font/woff2" crossorigin /> 3<link rel="prefetch" href="/checkout.chunk.js" />
Images are the largest payload on most pages, which makes them the biggest available win:
srcset and sizes so a phone doesn't download a desktop-resolution file.loading="lazy" for anything below the fold, and never for the LCP image.decoding="async" so decoding doesn't block rendering.HTMLindex.html1<img 2 src="/photo-800.jpg" 3 srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w" 4 sizes="(max-width: 600px) 400px, 800px" 5 loading="lazy" 6 decoding="async" 7 width="800" height="600" 8 alt="room" />
Reading a layout property — offsetHeight, getBoundingClientRect, scrollTop — forces the browser to synchronously flush any pending layout changes so it can give you an accurate answer. Interleaving reads and writes in a loop is layout thrashing, and it turns a batched operation into one reflow per iteration:
JSfile.js1// Bad — every read forces a synchronous layout 2items.forEach((el) => { el.style.height = el.offsetHeight * 2 + 'px'; }); 3 4// Good — all reads, then all writes 5const heights = items.map((el) => el.offsetHeight); 6items.forEach((el, i) => { el.style.height = heights[i] * 2 + 'px'; });
transform and opacity skip layout and paint entirely and run on the GPU compositor thread. Everything else does not:
CSSstyles.css1/* Smooth — compositor only */ 2.modal { transition: transform 0.2s, opacity 0.2s; } 3 4/* Janky — reflows every frame */ 5.modal { transition: top 0.2s, height 0.2s; }
will-change: transform hints the browser to promote an element to its own layer in advance. Use it sparingly — every layer costs memory, and over-applying it makes things worse.
Schedules visual updates to run immediately before the next paint, synchronised to the display refresh rate. It's the correct primitive for animation, and for batching DOM writes:
JSfile.js1function animate() { 2 el.style.transform = `translateX(${x}px)`; 3 x += 2; 4 if (x < 300) requestAnimationFrame(animate); 5} 6requestAnimationFrame(animate);
Unlike setTimeout, it pauses in background tabs and never fires more often than the screen can display.
JavaScript is single-threaded, so heavy computation on the main thread freezes the UI and destroys INP. Parsing large files, sorting big datasets, image processing — all of it belongs on a worker:
JSfile.js1const worker = new Worker('parser.js'); 2worker.postMessage(largeDataset); 3worker.onmessage = (e) => render(e.data); // UI stays responsive throughout
Render only the rows currently visible, plus a small buffer, instead of thousands of DOM nodes. react-window and react-virtualized are the usual tools.
The property that matters: DOM node count stays constant regardless of list length. A ten-thousand-row table costs the same as a fifty-row one, so scrolling stays smooth and memory stays flat.
Immutable hashed assets. Bundle files carry a content hash in the filename — app.a3f9c.js — and ship with Cache-Control: max-age=31536000, immutable. The hash changes when the content does, so you can cache forever and cache-bust automatically on every deploy.
HTML gets a short cache or no-cache, so users pick up new asset references promptly.
Service workers give you a programmable cache for offline and PWA support, with strategies like cache-first, network-first, and stale-while-revalidate.
Serving static assets from edge servers geographically near your users cuts latency substantially and takes load off your origin. It pairs naturally with immutable hashed assets, since those can be cached at the edge indefinitely.
gzip is the universally supported baseline. Brotli achieves better ratios on text — HTML, CSS, JavaScript — and is preferred wherever the client supports it. The server negotiates via Accept-Encoding and Content-Encoding.
Compressing text assets is among the cheapest large wins available.
Tree shaking and code splitting do the structural work. Beyond that: swap heavy dependencies for lighter ones — date-fns instead of moment is the standard example — use dynamic imports for rarely-touched features, and run a bundle analyser to find out what's actually large rather than what you assume is.
The principle that governs everything: measure first, optimise second. Intuition about what's slow is wrong often enough that acting on it wastes real time.
Identify the symptom. Is the page slow to load, or slow to respond? These are different problems with different tools and different fixes, and conflating them is the most common wrong turn.
Measure with the right tool:
Diagnose against the metric:
Fix the biggest bottleneck, then measure again. Optimisation is iterative. Change one thing, confirm it helped, and only then move on. A fix that seemed obvious and did nothing is extremely common, and you'll only know if you re-measure.
Core Web Vitals — LCP for loading, INP for responsiveness (it replaced FID in 2024), CLS for stability. Preload the hero and never lazy-load it, break up long tasks, reserve space for anything that loads late.
Loading — split by route first, tree-shake with ES modules, use the right resource hint, and treat images as the biggest available win.
Runtime — batch reads before writes, animate transform and opacity only, offload heavy work to workers, virtualize long lists.
Network — immutable hashed assets behind a long cache, a CDN, Brotli, and a bundle you've actually measured.
Method — measure, diagnose against the metric that's failing, fix the largest bottleneck, re-measure.
Goal: Build a complete, interview-ready performance mental model: Core Web Vitals (including INP replacing FID in 2024), loading strategies, runtime optimization, network caching, and a structured "how to debug a slow page" answer.
Continue learning with these related challenges
Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.
Performance · Frontend · JavaScript · Browser — Pratik Rai ·
A practical guide to crawling, indexing, meta tags, semantics, and Core Web Vitals so your pages rank and stay fast.
SEO · Web Performance · Frontend — Pratik Rai ·
Learn how I cut CSS bundle size by 85% and improved page load times by 50% using dynamic imports and automatic code splitting in Next.js—a simple pattern with massive performance impact.
Next.js · React · Performance · CSS — Pratik Rai ·
Understanding frontend performance optimization from the ground up - learn how browsers work and optimize your code accordingly.
Performance · Frontend · JavaScript
Pratik Rai ·
A practical guide to crawling, indexing, meta tags, semantics, and Core Web Vitals so your pages rank and stay fast.
SEO · Web Performance · Frontend
Pratik Rai ·
Learn how I cut CSS bundle size by 85% and improved page load times by 50% using dynamic imports and automatic code splitting in Next.js—a simple pattern with massive performance impact.
Next.js · React · Performance
Pratik Rai ·