Most web performance advice is a list of tactics: inline your critical CSS, defer your scripts, preconnect to origins. The tactics are fine. The problem is that applying them without knowing which stage is actually slow is guesswork, and you can spend a sprint optimising a stage that costs you eight milliseconds.
So this is a teardown rather than a checklist. What happens, in order, between someone clicking a link and seeing pixels — and how to tell where your time is going.
Stage 1: Finding the server
Before a single byte of your site is requested, the browser needs an IP address. If the hostname is not in the OS or browser cache, that is a DNS lookup — typically 20-120ms, and on a poor mobile connection it can be far worse.
Then TCP: a three-way handshake costing one full round trip. Then TLS: another one to two round trips depending on version, with TLS 1.3 managing it in one for a new connection and zero for a resumed one.
Add that up on a 100ms round-trip connection and you have spent 300-400ms before requesting anything. This is why third-party origins are so expensive — every distinct hostname pays this toll again. A page pulling fonts from one domain, analytics from another, and a widget from a third pays it three times, in parallel but each still on the critical path if the resource is render-blocking.
The mitigations are cheap and specific:
<!-- Warm the whole connection: DNS + TCP + TLS, before it is needed -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Cheaper, DNS only. For origins you are less sure you will use -->
<link rel="dns-prefetch" href="https://analytics.example.com">
Use preconnect sparingly — each one holds a connection open, and browsers cap how many they will honour. Four or five is reasonable; twenty is counterproductive.
Stage 2: Waiting for the first byte
The request arrives, your server does its work, and the first byte comes back. That interval is TTFB, and it is the only stage where the fix is entirely on the server: database queries, template rendering, cache misses, cold serverless starts.
What people miss is that the browser can start parsing before the response finishes. HTML is streamed and parsed incrementally. If you render the entire page server-side and flush it in one go at the end, the browser sits idle during all of that generation. If you flush the <head> early, the browser starts fetching your stylesheets while your server is still assembling the body.
That is the whole idea behind streaming SSR, and it is a structural win rather than a micro-optimisation — the discussion in our server components guide covers how modern frameworks express it.
Stage 3: Parsing HTML, and the preload scanner
The browser parses HTML into the DOM incrementally. When it encounters a resource it needs, it dispatches a request — but there is a second, cleverer mechanism running alongside.
The preload scanner is a lightweight secondary parser that races ahead of the main one looking for URLs to fetch early. It is why a page with ten images does not fetch them strictly one at a time.
The catch: the preload scanner only sees markup. Resources injected by JavaScript are invisible to it. So this:
<img src="/hero.jpg" alt="…"> <!-- scanner finds it immediately -->
is meaningfully faster than this:
// Scanner cannot see this. The fetch waits for JS to parse, execute, and run.
const img = new Image();
img.src = '/hero.jpg';
document.body.appendChild(img);
The same applies to lazy-loading libraries that replace src with data-src. You have hidden your most important image from the one mechanism designed to find it early. For a hero image — almost always your Largest Contentful Paint element — that is a direct regression, and it is why loading="lazy" on an above-the-fold image is a mistake rather than an optimisation.
Stage 4: CSS blocks rendering, and it should
CSS is render-blocking by default, and unlike most blocking behaviour this is deliberate and correct. If the browser painted before the stylesheet arrived, you would see unstyled content flash and then rearrange — worse than a brief delay.
So the browser will not paint until it has built the CSSOM from every render-blocking stylesheet. One slow stylesheet holds the entire page blank, no matter how fast everything else was.
Two things follow. First, media queries make a stylesheet non-blocking for contexts where it does not apply:
<link rel="stylesheet" href="print.css" media="print"> <!-- never blocks screen render -->
<link rel="stylesheet" href="wide.css" media="(min-width: 1200px)">
Second, @import inside CSS is genuinely harmful. The browser must download and parse the outer stylesheet before it discovers the import, then start a fresh request — serialising two round trips that could have been parallel. The preload scanner cannot help, because the URL is buried inside a file it has not read yet.
The standard fix for a slow first paint is inlining the styles needed for above-the-fold content into a <style> block and loading the rest asynchronously. It works, and it is worth being honest about the cost: inlined CSS is not cached separately, so you re-send it on every navigation. It is a good trade for a landing page and a poor one for an application where users navigate repeatedly.
Stage 5: Scripts, and the three ways to load them
A plain <script> tag is parser-blocking. The browser stops building the DOM, downloads the script, executes it, then resumes. It has to, because the script might call document.write.
You almost never want that:
<script src="app.js"></script>
<!-- Stops parsing. Downloads. Executes. Then continues. -->
<script src="app.js" async></script>
<!-- Downloads in parallel; executes the moment it lands, interrupting parsing.
Order between multiple async scripts is not guaranteed. -->
<script src="app.js" defer></script>
<!-- Downloads in parallel; executes after parsing completes, in document order. -->
The practical rule: defer for anything that touches the DOM or depends on another script, async for genuinely independent things like analytics. Placing a blocking script at the end of <body> is the old workaround and it is strictly worse than defer, because the browser does not discover the URL until it has parsed everything above it.
Execution cost is separate from download cost and often larger. A 300KB JavaScript bundle does not just cost transfer time — it costs parse, compile, and execute time on the main thread, and on a mid-range phone that can be several hundred milliseconds during which nothing else happens. This is the connection to interaction latency: a long task blocks input handling entirely, which is the mechanism behind most poor INP scores.
Stage 6: Layout
With DOM and CSSOM built, the browser computes the render tree and runs layout — calculating the geometry of every visible box. It is a whole-document operation and it is not cheap.
The expensive pattern is layout thrashing: reading a geometric property forces the browser to flush pending layout so it can answer accurately. Interleave reads and writes in a loop and you force a synchronous layout on every iteration.
// Thrashing: each read forces a layout because the previous write invalidated it
elements.forEach(el => {
const w = el.offsetWidth; // read -> forces layout
el.style.width = (w * 2) + 'px'; // write -> invalidates layout
});
// Batched: all reads, then all writes. One layout instead of N.
const widths = elements.map(el => el.offsetWidth);
elements.forEach((el, i) => { el.style.width = (widths[i] * 2) + 'px'; });
The properties that trigger this are the geometric ones — offsetWidth, offsetTop, getBoundingClientRect(), scrollTop, getComputedStyle(). Reading them is not free, and reading them in a loop after writing is pathological.
Layout is also where Cumulative Layout Shift comes from. An image without width and height attributes occupies zero space until it loads, then suddenly claims its real size and shoves everything below it down. Always set dimensions, or an aspect-ratio, so the browser can reserve the space in advance.
Stage 7: Paint and composite
Paint fills in pixels: text, colours, shadows, images. Compositing assembles the painted layers into the final frame, and this is where the GPU earns its keep.
The distinction matters because different CSS properties re-enter the pipeline at different stages. Changing width triggers layout, then paint, then composite. Changing background-color skips layout but repaints. Changing transform or opacity can skip both and go straight to compositing — which is why they are the only two properties you should animate if you care about frame rate.
/* Triggers layout on every frame. Janky. */
.slide { transition: left 300ms; }
/* Compositor-only. Smooth even on weak hardware. */
.slide { transition: transform 300ms; will-change: transform; }
Use will-change with restraint — it promotes an element to its own layer, and layers cost memory. Applying it to everything degrades performance rather than improving it. Add it before an animation and remove it after, or leave it off entirely for animations that are already smooth.
If you are animating between whole page states rather than individual elements, the browser now offers a better primitive than hand-rolled transitions — our View Transitions API guide covers it.
Fonts: the resource that blocks text specifically
Web fonts have their own failure mode, and it is the one users complain about without knowing the name. A font is discovered late — the browser only requests it after parsing CSS and determining that some element actually uses it — and until it arrives, the browser must decide what to show.
That decision is font-display, and the default is genuinely bad:
@font-face {
font-family: 'Inter';
src: url('/inter.woff2') format('woff2');
font-display: swap; /* show fallback immediately, swap when ready */
/* default is 'auto', which most browsers treat as 'block':
invisible text for up to 3 seconds. */
}
The default produces a flash of invisible text — a laid-out page with blank spaces where words should be. swap renders the fallback immediately and swaps when the web font arrives, which shows a flash of unstyled text instead. Neither is free, but text the user can read beats text they cannot.
The layout shift from swapping is real and worth mitigating. When the fallback and the web font have different metrics, the swap reflows the text and everything below it moves — a direct hit to Cumulative Layout Shift. The size-adjust, ascent-override, and descent-override descriptors let you tune a fallback to match the web font’s metrics closely enough that the swap is barely visible.
Two more wins that cost nothing. Subset your fonts to the characters you actually use — a full Unicode font can be ten times the size of a Latin subset. And preload the fonts used above the fold, since preloading is the only way to start the request before CSS has been parsed:
<link rel="preload" href="/inter.woff2" as="font" type="font/woff2" crossorigin>
The crossorigin attribute is mandatory even for same-origin fonts. Omit it and the browser fetches the font twice — once for the preload, once for the real use — which is worse than not preloading at all. This catches people constantly and produces no warning.
Images: usually your largest byte cost
Images are typically the heaviest thing on a page and the LCP element is usually one, so this is where byte-level work pays off most.
Format first. AVIF and WebP are dramatically smaller than JPEG at equivalent quality, and <picture> lets you offer them with a fallback:
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg" alt="…" width="1200" height="630"
fetchpriority="high" decoding="async">
</picture>
Then size. Serving a 3000px-wide image to a 375px phone wastes bandwidth and decode time, and decode is not free — a large image costs main-thread milliseconds to decompress even after it has arrived. srcset with sizes lets the browser pick appropriately for the device.
fetchpriority="high" on the LCP image is worth knowing about. Browsers assign priorities heuristically and often deprioritise images discovered early because they might be below the fold. Marking the hero explicitly moves it up the queue, and on image-heavy pages the effect on LCP can be substantial for a one-attribute change.
The corresponding mistake, worth repeating because it is so common: loading="lazy" on an above-the-fold image actively delays your LCP. Lazy loading is for images below the fold. Applying it site-wide as a blanket optimisation makes your most important image load last.
Protocol and caching: the stages you only pay once
Everything above concerns a cold load. Repeat visits are governed by caching, and the difference between a well-cached site and a poorly-cached one is larger than most render-path tuning.
The pattern worth adopting is immutable content-hashed assets with long lifetimes, and a short or revalidated lifetime on the HTML that references them:
# Hashed filename changes whenever content changes -> cache forever
Cache-Control: public, max-age=31536000, immutable
# HTML must revalidate, since it names the current asset hashes
Cache-Control: no-cache
immutable is the useful part: it tells the browser not to revalidate even on a hard refresh, eliminating a round trip per asset. It is only safe with content-hashed filenames, but with them it is entirely safe.
On protocols, HTTP/2 removed the head-of-line blocking that made domain sharding and file concatenation necessary, so those old optimisations are now counterproductive — they defeat caching granularity for no benefit. HTTP/3 goes further by moving to QUIC over UDP, which eliminates transport-level head-of-line blocking entirely and matters most on lossy mobile connections. Neither requires application changes; both are worth enabling at the edge.
One caveat on HTTP/2 Server Push: it was removed from Chrome because it usually pushed resources the browser already had cached, wasting bandwidth. If you find advice recommending it, that advice is stale. Use preload and 103 Early Hints instead — the latter lets a server send resource hints before the real response is ready, which recovers most of what Push promised without the waste.
Working out which stage is yours
All of the above is only useful if you can locate your own bottleneck. The waterfall in DevTools answers it faster than reasoning does.
A long bar before anything downloads is connection setup or TTFB — a server or infrastructure problem, and nothing in your CSS will help. A long gap between the HTML arriving and the first paint points at render-blocking CSS or a parser-blocking script. Resources starting late, well after the HTML landed, usually means they are being injected by JavaScript and the preload scanner never saw them.
In the Performance panel, the flame chart shows where main-thread time goes. Wide purple blocks are layout; green is paint; yellow is scripting. If yellow dominates, your problem is JavaScript execution and no amount of CSS tuning will move it.
Two habits make this measurement honest. Throttle to a mid-tier device and a 4G connection, because your development machine on office broadband is nobody’s real experience. And check field data rather than lab data — your own load is a single sample, and the p75 of real users is what actually matters.
The order that saves time
Fix connection setup and TTFB first, because they delay everything downstream and no client-side work compensates for them. Then remove render-blocking resources from the critical path. Then reduce JavaScript execution, which is usually the largest single cost on mobile. Then worry about paint and compositing, which matter for animation smoothness but rarely for initial load.
Working in that order means each fix compounds. Working in the reverse order — which is the natural instinct, because compositing tricks are the fun part — means optimising a stage that was never the bottleneck.