May 24, 2026
· 14 min readYour Site Looks Fine — Until It Loads on a Phone
Most slow sites aren't badly built — they just do too much work to load. This guide walks the critical rendering path step by step, then fixes the four things Lighthouse always flags: CSS, images, JavaScript, and fonts. Includes the delivery layer (CDN, HTTP/3, Brotli, caching) and how to stop performance from eroding after you fix it.

TL;DR
- Performance bugs are almost always the browser's rendering path getting blocked — by CSS, a script tag, or a font. Learn the five steps and most fixes become obvious.
- The three metrics that matter: LCP (loading), CLS (stability), INP (responsiveness). INP replaced FID on March 12, 2024. Source: web.dev.
- CSS is render-blocking by default. Inline critical CSS only when your stylesheet is over ~100 KB; otherwise just ship less.
- JavaScript hurts more per kilobyte than anything else because it parses and executes on the main thread. Code-split,
defer, and audit your bundle. Swapping Moment.js (~72 KB gzipped) for Day.js (~2 KB) is a textbook win. - Fonts flash or hide text.
font-display: swap, self-host, preload withcrossorigin, ship variable fonts. - Delivery: a CDN gets you HTTP/2 + HTTP/3 for free. Cache hashed assets for a year, turn on Brotli, and
preconnectto third parties. - Fix it, then guard it with a performance budget — otherwise every new feature quietly undoes your work.
Why this matters
Here's a scenario you've probably lived. A site looks fine on your machine. Then you open it on a phone on a real network and nothing shows up for three seconds. The hero takes six. The layout keeps jumping as fonts load. The Lighthouse score is bad.
And the code is fine. The site isn't badly built. It's just doing too much work to load.
That gap — between "the code is correct" and "the page feels fast" — is what performance work is about. Before touching anything, you need a shared vocabulary for what "fast" actually means.
There are three metrics, and they're the ones Google now treats as Core Web Vitals:
| Metric | Full name | What it measures | "Good" threshold |
|---|---|---|---|
| LCP | Largest Contentful Paint | How long the biggest visible element (usually a hero image or headline) takes to render | ≤ 2.5 s |
| CLS | Cumulative Layout Shift | Unexpected layout movement — the ad that loads and pushes the button you were about to tap | ≤ 0.1 |
| INP | Interaction to Next Paint | How long after a tap/click before the page visibly responds | ≤ 200 ms |
INP thresholds and definitions per Google Search Central and tryseo.de.
Important: INP officially replaced FID (First Input Delay) on March 12, 2024. FID only measured the delay before the browser started responding to the first tap. INP looks at every interaction across the page's lifetime — a much more honest measure of how responsive your app feels. Source: web.dev.
Speed, stability, responsiveness. Every fix below targets one of these three.
How the browser actually loads a page
To know which fixes work, you have to know what the browser does when it loads a page. It's a five-step pipeline called the critical rendering path, and most performance bugs are one of those steps getting blocked.
Walk through it:
- The browser downloads the HTML and starts parsing.
- When it hits a
<link rel="stylesheet">, it stops. It can't draw anything until it knows how things should look — so it pauses, downloads the CSS, and parses it. - It merges HTML and CSS into a render tree: what to draw and how to style it.
- Layout figures out where everything goes.
- Paint puts pixels on the screen.
Anything that interrupts these steps delays the page. A big CSS file blocks. A plain <script> tag blocks. A web font with the wrong setting holds up your text. Almost every performance fix is just a way to stop blocking this path.
Audit first: Lighthouse
Open DevTools → Lighthouse tab. Always set it to Mobile, even if your users are mostly on desktop — mobile devices are slower and have worse networks, so problems surface there first. Run it, and below the score you'll get a prioritized list of what's slow.
Those warnings almost always fall into four buckets: CSS, images, JavaScript, and fonts. Let's take them one at a time.
Fix 1 — CSS
You know that fraction of a second where a page is half-styled, or blank when it should be loaded? CSS is the usual cause.
CSS is render-blocking by default. Until every stylesheet in your <head> is downloaded and parsed, the browser paints nothing. That means your CSS file sits on the critical path for LCP — including the rules for your footer and the modal nobody opened.
Critical CSS
The classic fix is critical CSS: extract the styles needed to render the visible part of the page, inline them in the <head>, and load the rest in the background. The browser paints immediately with what it has while the remaining styles arrive.
<head>
<!-- Inlined: just enough to paint the hero -->
<style>/* critical above-the-fold rules */</style>
<!-- Everything else, loaded without blocking paint -->
<link rel="preload" href="/styles.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>Doing this by hand is painful, so most teams use a build-time tool. Beasties — the maintained fork of the older critters — does this well for static and pre-rendered pages, and several frameworks ship ready-made integrations.
💡 Tip:
GoogleChromeLabs/critterswas archived in October 2024. Its maintained successor isdanielroe/beasties, now under the Nuxt team. Source: critters README.
But don't reach for it reflexively. Critical CSS pays off when your stylesheet is over ~100 KB and your hero is still waiting on it. Below that, the setup isn't worth the pain.
| When to use critical CSS | When to skip it | |
|---|---|---|
| Stylesheet size | Over ~100 KB | Under ~100 KB |
| Page type | Static / pre-rendered / SSR | Small bundles |
| Effort | Build-time tooling (Beasties) | Just ship less CSS |
Ship less CSS
The other big lever is simply shipping less of it. If you use Tailwind, this is automatic — only the classes you actually used end up in the build. For traditional CSS, PurgeCSS does the same thing by stripping unused selectors.
content-visibility for long pages
If your pages are long — product pages, blog posts, feeds — there's a CSS property worth knowing:
.below-the-fold-section {
content-visibility: auto;
contain-intrinsic-size: auto 500px; /* reserve space to avoid scroll jumps */
}Apply it to sections below the fold and the browser skips rendering them until the user scrolls close. It's like lazy loading, but for layout work — one demo measured a 7x rendering boost on initial load. Source: web.dev.
⚠️ Note (corrected from common advice): You may have heard
content-visibilityonly became cross-browser in late 2025. That's outdated. Safari shipped support in Safari 18 (September 2024), and the property reached Baseline in 2024. It's a progressive enhancement either way — browsers without support simply skip the optimization, no breakage. Source: MDN.
Fix 2 — Images
On most sites, the heaviest thing on the page is still the images — and they're usually the slowest to load. Resizing them by hand isn't a real fix. The durable approach is to serve the right size and format automatically.
Three things to get right:
<!-- 1. Modern formats with fallback, 2. responsive sizes, 3. lazy-load below the fold -->
<img
src="/hero-1280.jpg"
srcset="/hero-640.jpg 640w, /hero-1280.jpg 1280w, /hero-1920.jpg 1920w"
sizes="(max-width: 768px) 100vw, 1280px"
width="1280" height="720"
loading="lazy"
decoding="async"
alt="…">Breaking it down:
srcset+sizes— phones get smaller files, retina screens get sharper ones. Frameworks like Next.js generate this for you.- Modern formats — serving AVIF or WebP with a JPEG fallback routinely takes a 4 MB hero down to ~100 KB at the same visual quality.
width+height— always set intrinsic dimensions. Without them, the image has no reserved space and shifts the layout when it loads — a direct CLS hit.loading="lazy"— for below-the-fold images only. Never lazy-load your LCP image; it's the one you want eagerly fetched.
An image CDN (any of the major ones) can do the resizing and format negotiation through URL parameters, so you don't rebuild assets by hand. The principle matters more than the vendor: don't ship a 4 MB image when a 100 KB one looks identical.
Fix 3 — JavaScript
Pound for pound, JavaScript slows your site down more than anything else. Here's the key insight:
100 KB of JavaScript and 100 KB of image take the same time to download — but JS then has to be parsed, compiled, and executed on the main thread. The same thread that handles clicks and scrolls. So you don't just wait for JS to load; you wait for it to load, then wait for the browser to process it. During that processing, the page can't respond. That's where INP suffers.
Three ways to attack it.
1. Code splitting
If a component isn't on screen at load, it doesn't belong in the initial bundle. Switch to a dynamic import so the chart library doesn't load until the user scrolls to the chart:
import { lazy, Suspense } from "react";
// Loaded only when actually rendered
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<HeavyChart />
</Suspense>
);
}Every framework has its own syntax for this, but the idea is identical.
2. Fix your script tags
A plain <script> is the worst case: the browser stops parsing HTML, downloads the script, runs it, then continues. Adding defer fixes it.
<!-- ❌ Blocks HTML parsing -->
<script src="/analytics.js"></script>
<!-- ✅ Parses HTML to completion, then runs in order -->
<script src="/analytics.js" defer></script>
<!-- ⚡ Downloads in parallel, runs whenever ready (use sparingly) -->
<script src="/widget.js" async></script>| Attribute | Download | Execution | Use for |
|---|---|---|---|
| (none) | Blocks parsing | Immediately, blocks parsing | Almost never |
defer |
In parallel | After HTML parses, in order | Analytics, tracking, most third-party tags |
async |
In parallel | As soon as ready, can interrupt | The rare script with no dependencies and no order requirement |
For almost everything you add to a real app — analytics, tracking, third-party tags — defer is what you want.
3. Audit the bundle
Run a bundle analyzer. You'll almost certainly find something you imported once and forgot, or a heavy library doing one tiny job. The textbook case:
| Library | Job | Size (gzipped) |
|---|---|---|
| Moment.js (all locales) | Format a date | ~72 KB |
| Day.js | Format a date | ~2 KB |
That's a ~35x difference on something you don't think twice about, with a near-identical API. Both install in five minutes. Sizes per codingislove.com and Better Stack.
💡 Tip: If you've never run a bundle analyzer against your own app, do it once. It's the single highest-ROI hour in this whole list.
All three techniques reduce to one rule: ship less JavaScript. Bytes that aren't in the bundle can't slow anything down.
Fix 4 — Fonts
Fonts fail in two ways:
- FOIT — flash of invisible text. The browser hides text entirely until the font loads. The page looks broken for a second.
- FOUT — flash of unstyled text. Text appears in a fallback font, then swaps when the custom font arrives. A flicker, but the content is readable the whole time.
Almost always, you want the flicker:
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-display: swap; /* → FOUT instead of FOIT */
}Then four more wins:
Self-host the WOFF2. Google Fonts is convenient, but the request goes to two domains — one for the CSS, one for the file — and each needs a fresh connection. Hosting the WOFF2 in your own project shares your existing connection and removes a third-party dependency.
Preload the hero weight. Tell the browser to fetch it the moment it parses the head, instead of waiting until the CSS asks:
<link rel="preload" href="/fonts/inter-var.woff2" as="font"
type="font/woff2" crossorigin>⚠️ Warning: Fonts always need the
crossoriginattribute on preload, even when self-hosted. Without it, the browser downloads the font twice.
Use a variable font. A single file containing every weight is usually about the size of two static weights. If you ship three or more weights today, switching to the variable version is a free win.
Subset it. Most font files include glyphs for languages your site doesn't use. If you ship in English, you don't need the Vietnamese glyphs. A tool like glyphhanger strips them — meaningful savings on larger fonts.
Fix 5 — Delivery
You can do everything above and still be slow if your server is in one region and your users are everywhere.
A CDN puts copies of your static files — JS, CSS, fonts, images — on servers near your users, so bytes don't cross an ocean. And once you're on a CDN, you usually get HTTP/2 and HTTP/3 for free:
- HTTP/2 lets the browser send many files through one connection (no more head-of-line queueing per resource).
- HTTP/3, built on QUIC, keeps that connection alive when a user switches from Wi-Fi to cellular.
Neither needs a code change.
Caching for the second visit
The Cache-Control header tells the browser how long it can hold a file before re-checking. For files with hashed names (app.A3F9C2.js), the right setting is aggressive:
Cache-Control: max-age=31536000, immutableChange the file, the hash changes, the URL changes, and the old cache is discarded automatically. Webpack, Vite, and Rollup all hash by default — you usually don't have to do anything.
Compression
Make sure Brotli is on. It's about 15% smaller than gzip on JavaScript (and ~16% on CSS, ~20% on HTML), every modern browser supports it, and most CDNs enable it by default. Source: Cloudways.
# Quick check — look at the response header in the Network tab
content-encoding: br # ✅ you're set
content-encoding: gzip # ⚠️ easy win available| gzip | Brotli | |
|---|---|---|
| JS savings vs uncompressed | good | ~15% better than gzip |
| Browser support | universal | ~96%, all modern browsers |
| Best for | dynamic responses (fast compress) | static assets (precompressed at build/edge) |
| Header value | gzip |
br |
Preconnect to third parties
If your page hits a third-party domain — an API, an embedded video, an analytics service — open the connection early:
<link rel="preconnect" href="https://api.example.com" crossorigin>The browser does the DNS and TLS handshake in advance, so by the time the real request fires, the connection's already open.
Production checklist
- Audit on Mobile in Lighthouse before changing anything — problems surface there first.
- Inline critical CSS only if your stylesheet exceeds ~100 KB; otherwise ship less (Tailwind JIT / PurgeCSS).
content-visibility: autoon long below-the-fold sections, paired withcontain-intrinsic-size.- Serve responsive, modern-format images; set explicit
width/height; lazy-load everything except the LCP image. - Code-split off-screen components; add
deferto every non-critical script. - Run a bundle analyzer and replace heavyweight libraries (Moment.js → Day.js).
font-display: swap, self-host WOFF2, preload withcrossorigin, prefer variable fonts, subset glyphs.- Serve from a CDN with HTTP/3, Brotli, and
Cache-Control: max-age=31536000, immutableon hashed assets. preconnectto third-party origins you'll definitely hit.- Set a performance budget in CI so regressions fail the build, not production.
When this is worth it — and when it isn't
- Do prioritize LCP and INP work on landing pages, product pages, and anything indexed by search — Core Web Vitals are a ranking signal and a conversion lever.
- Do start with the single biggest Lighthouse offender. One oversized hero image often beats a week of micro-optimizations.
- Don't set up critical CSS for a 30 KB stylesheet, or chase a 5 ms INP gain on an internal admin tool nobody complains about. Match the effort to the payoff.
Guard it — performance doesn't stay fixed
Here's the part most teams skip. Performance erodes if you don't actively defend it. Every new feature, every added dependency, quietly nudges you back toward where you started.
So set a budget. Lighthouse CI — or any monitoring that fails the build when the bundle grows past a threshold or the score drops — turns regressions into a CI failure instead of a production incident.
# lighthouserc.yml — fail the build if the bundle or scores regress
ci:
assert:
assertions:
resource-summary:script:size: ["error", { maxNumericValue: 170000 }]
categories:performance: ["error", { minScore: 0.9 }]Fix it, then guard it.
Conclusion
I've shipped enough "the code is fine but the site feels slow" projects to know the fix is rarely clever — it's almost always less. Less CSS on the critical path, fewer kilobytes of JavaScript on the main thread, smaller images, leaner fonts, and bytes served from an edge near the user.
Start where Lighthouse points you, on a mobile profile, and fix the biggest single offender first. Then make the win permanent with a performance budget in CI. Walk the critical rendering path once with this checklist in hand, and most of your "mysteriously slow" pages stop being mysterious.
FAQ
What are LCP, CLS, and INP?
LCP (Largest Contentful Paint) measures how long the biggest visible element takes to render. CLS (Cumulative Layout Shift) measures unexpected layout movement. INP (Interaction to Next Paint) measures how quickly the page responds to a tap or click. They cover loading, stability, and responsiveness respectively.
Did INP really replace FID?
Yes. Interaction to Next Paint officially replaced First Input Delay as a Core Web Vital on March 12, 2024. FID only measured the delay before the browser responded to the first interaction; INP looks at every interaction throughout the page's life.
Do I always need critical CSS?
No. Critical CSS pays off when your stylesheet is over ~100 KB and your hero is waiting on it. Below that, the setup cost outweighs the gain — just ship a smaller bundle instead.
Is content-visibility safe to use in 2026?
Yes. content-visibility became Baseline (supported across Chrome, Firefox, and Safari) in 2024, with Safari support arriving in Safari 18. It's a progressive enhancement, so older browsers simply skip the optimization with no breakage.
Should I use Brotli or gzip?
Use Brotli for static, precompressed assets — it is roughly 15% smaller than gzip on JavaScript. Keep gzip as a fallback for dynamic responses and older clients. Most CDNs negotiate this automatically via the Accept-Encoding header.
How do I stop my site from getting slow again?
Set a performance budget. Tools like Lighthouse CI fail the build when the bundle grows past a threshold or the score drops, so regressions get caught in CI instead of in production.