If you have ever opened a beautiful new website on your phone and watched the layout shift around for two seconds while you tried to tap a button, you have met bad web performance. You are not alone. According to the 2025 Web Almanac, only 48% of mobile pages and 56% of desktop pages pass all three Core Web Vitals.
Performance is the gap between "the design looks great" and "people actually use it". A 24% lower bounce rate is the kind of number a CEO will print on a poster. It comes from making the page feel fast.
That is the gap web performance fills.
What is web performance, really
Think of web performance as measuring three feelings the user has, not measuring how clever your code is:
- "Did the page show up?" (LCP, Largest Contentful Paint)
- "Does it respond when I tap?" (INP, Interaction to Next Paint)
- "Is it staying still or jumping around?" (CLS, Cumulative Layout Shift)
Google calls these Core Web Vitals. They are user perception metrics, not synthetic numbers. Google measures them on real users. They feed search rankings. Browsers expose them via the Performance API for free.
The 2026 thresholds you must memorize:
| Metric | Good | Needs work | Poor |
|---|---|---|---|
| LCP | ≤ 2.5 s | 2.5 to 4.0 s | > 4.0 s |
| INP | ≤ 200 ms | 200 to 500 ms | > 500 ms |
| CLS | ≤ 0.1 | 0.1 to 0.25 | > 0.25 |
INP replaced FID (First Input Delay) in March 2024 and is the strictest Core Web Vital. 43% of sites fail the 200ms INP threshold. Most of the work in 2026 is here.
That is the whole vibe.
Let's pretend we are building one
We want a way to make websites feel fast on real devices, on real networks, in real users' hands. We will not invent the metrics. The browser already gives them to us. We just need to learn to read them and to fix what they reveal.
For the running example, we are speeding up a tiny online bakery landing page: a hero image, a list of products, a "buy" button. We will improve each metric in turn.
Decision 1: Measure first, optimize second
Before you change anything, measure. Three places to measure, in order:
Lighthouse / PageSpeed Insights
Open Chrome DevTools, hit the Lighthouse tab, run a report. Or paste your URL into pagespeed.web.dev. You get scores for each metric, plus specific suggestions ranked by impact.
This is lab data, run on a simulated mobile device. Useful for catching regressions, but does not match what real users see.
CrUX (Chrome User Experience Report)
Real anonymized data from real Chrome users. The PageSpeed report shows it at the top under "field data". This is the data Google uses for ranking. Trust this number more than the Lighthouse one.
Real User Monitoring (RUM)
Send your own metrics from production using the web-vitals library. Two minutes of setup gives you the truth, on every device, every connection, every page.
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
// ship to your analytics endpoint
navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
}
onLCP(send);
onINP(send);
onCLS(send);
The senior level rule: Lighthouse for trends, CrUX for the truth, RUM for debugging.
Decision 2: Improve LCP, the "did the page show up" metric
LCP measures the time until the largest visible element finishes rendering. Almost always: a hero image, a hero <h1>, or a big text block. If LCP is slow, the page feels broken even if everything else is fine.
The four highest impact fixes, in order:
Preload the LCP image
Tell the browser the most important image early, while it is still parsing HTML.
<link
rel="preload"
as="image"
href="/hero-bakery.jpg"
fetchpriority="high"
/>
In Next.js, priority on <Image> does the same thing:
<Image src="/hero-bakery.jpg" alt="..." width={1200} height={800} priority />
Use modern image formats and responsive sizes
AVIF is roughly half the size of JPEG. WebP is roughly 25% smaller. Browsers support them. Serve them.
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="..." width="1200" height="800" />
</picture>
Always serve a size that fits the viewport. A phone does not need a 4K hero. Use srcset with descriptors:
<img
src="/hero-1200.jpg"
srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1600.jpg 1600w"
sizes="(max-width: 600px) 100vw, 1200px"
alt="..."
/>
A tool like Sharp or a CDN like Cloudinary or Vercel Image Optimization gives you these sizes automatically.
Inline critical CSS, defer the rest
Render blocking CSS delays paint. Inline the styles your above-the-fold content needs in <style>, then load the full stylesheet <link rel="preload" as="style"> and apply asynchronously.
Most teams do not do this by hand. Frameworks handle it. If yours does not, run a tool like Critters or use a service that does.
Server side render the first paint
Static HTML (SSG, ISR, RSC, plain server rendering) puts visible content on the screen before any JavaScript executes. That is the single biggest win you will ever ship.
If your app is a Vite SPA where everything renders inside <div id="root"></div> on the client, your LCP is at the mercy of the JS bundle. If you can move to Next.js, Astro, Remix, or a framework that ships HTML, do.
Self host fonts and use font-display: swap
A custom font that takes 800ms to load and blocks text with font-display: block will torpedo your LCP. The fix:
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap;
font-weight: 100 900; /* variable font, one file for all weights */
}
font-display: swap shows fallback text immediately and swaps to the custom font when it loads. Then preload the file:
<link rel="preload" as="font" type="font/woff2" href="/fonts/inter.woff2" crossorigin />
Better yet, use next/font (in Next.js) or unplugin-fonts (everywhere else). They handle subsetting, preloading, and size-adjust to prevent CLS when the font swaps.
Decision 3: Improve INP, the "does it respond" metric
INP is the time between a user interaction (tap, click, keypress) and the next paint after the resulting work. If your button takes 600ms to react, INP fails.
The pain almost always comes from a long task on the main thread blocking the browser from painting. JavaScript is single threaded. While it is busy, nothing else happens.
The senior level fixes:
Break long tasks into chunks
A 200ms for loop blocks the main thread for 200ms. Split it.
async function processInChunks<T>(items: T[], handle: (item: T) => void) {
const CHUNK = 50;
for (let i = 0; i < items.length; i += CHUNK) {
items.slice(i, i + CHUNK).forEach(handle);
await new Promise((r) => setTimeout(r, 0)); // yield to the browser
}
}
Or use the modern API directly:
async function yieldToMain() {
if ("scheduler" in window && "yield" in window.scheduler) {
return window.scheduler.yield();
}
return new Promise((r) => setTimeout(r, 0));
}
Inside long work, await yieldToMain() periodically. The browser gets a chance to paint and respond.
Use React's transitions for non urgent updates
Sometimes the work is React rendering. useTransition marks an update as low priority so the browser can paint the urgent stuff first.
const [isPending, startTransition] = useTransition();
function handleSearch(query: string) {
setQuery(query); // urgent: input updates immediately
startTransition(() => setResults(filter(allItems, query))); // not urgent
}
The input feels instant even when the result list is heavy.
Move heavy work off the main thread
If a function takes 400ms, it should not run on the UI thread. Use a Web Worker:
// worker.ts
self.onmessage = (e) => {
const result = expensiveCompute(e.data);
self.postMessage(result);
};
// main.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module" });
worker.postMessage(input);
worker.onmessage = (e) => setResult(e.data);
Tools like comlink make this much friendlier (the worker exposes a function, you await it from the main thread).
Cut your JavaScript bundle
The fastest function is the one that does not run. Common wins:
-
Code split by route. Next.js does this automatically. In Vite SPAs,
React.lazyplus a route loader does the trick. - Lazy load heavy widgets. Maps, charts, rich text editors should load only when used.
-
Tree shake your icon libraries. Importing a single icon from
lucide-reactis fine. Importing the whole namespace is not. -
Replace heavy dependencies.
date-fns/locale/enovermoment.nanoidoveruuid. - Use the Coverage tab in DevTools to see how much of your shipped JS is actually used. The number will surprise you.
Stop unnecessary re renders
A React component that re renders on every keystroke when it does not need to is INP poison. Tools to reach for:
-
React.memoon heavy components passed stable props. -
useMemofor expensive computations. -
useCallbackonly when the callback is a dependency of a memoed child. - The React Compiler (in 2026) handles most of this for you, but it is not magic. Inspect with the React DevTools Profiler.
Decision 4: Improve CLS, the "is it staying still" metric
CLS measures unexpected layout shifts: the page jumping while the user is reading or about to tap. Three causes account for almost every shift in the wild:
Images without dimensions
The browser does not know how big the image will be until it loads, so it reserves zero space. When the image arrives, everything below it shifts.
The fix is one line:
<img src="/cookie.jpg" alt="..." width="640" height="480" />
Or in CSS:
img { aspect-ratio: 4 / 3; height: auto; }
The browser uses width and height to compute an aspect ratio and reserves space immediately. This is a free CLS win that almost no one does.
Late loading fonts
A custom font usually has different metrics from the fallback. When it swaps in, every line of text shifts.
Mitigation:
-
font-display: optionalto skip the swap entirely on slow connections. -
size-adjust,ascent-override,descent-overridein@font-faceto make the fallback look like the custom font. - The
next/fontpackage and the modern Fontsource setup handle this for you.
Dynamic content (ads, embeds, banners) injected without reserved space
The fix is to always reserve the space before the content arrives. A skeleton, a placeholder div with a min height, an explicit aspect-ratio. Anything that holds the slot.
.ad-slot { min-height: 300px; }
.embed-yt { aspect-ratio: 16 / 9; }
If the dimensions are unknown, do not inject content above the user's viewport at all. Inject below.
Decision 5: The network is the bottleneck
A perfectly written app on a slow network is still slow. Two layers of fixes:
Compress everything
# nginx (or your CDN equivalent)
gzip on; # compresses text by 70%+
brotli on; # compresses 20-25% better than gzip
Most CDNs and platforms do this by default. Confirm in DevTools (Network tab, Headers, look for content-encoding). If you see text/css files at 100KB uncompressed, something is misconfigured.
Cache aggressively, invalidate precisely
Static assets (JS bundles, CSS, fonts, images) should be cached for a year, with a hash in the filename so a deploy busts the cache:
Cache-Control: public, max-age=31536000, immutable
HTML responses should be revalidated each time:
Cache-Control: no-cache
Most build tools (Vite, Next.js, Astro) handle hash naming automatically. Your CDN (Vercel, Netlify, Cloudflare) sets the headers.
For dynamic content, use stale-while-revalidate:
Cache-Control: public, max-age=60, stale-while-revalidate=600
Reads:
Trust this for 60 seconds. After that, keep serving the stale copy for up to 10 minutes while you fetch a fresh one in the background.
That single header is the secret to APIs that feel instant under load.
Use HTTP/2 or HTTP/3
If your server still answers in HTTP/1.1, you are wasting connections. Modern hosting gives you HTTP/2 or HTTP/3 (QUIC) for free. Multiplexing means dozens of small requests share a single connection, so you can stop bundling 200 modules into a single mega bundle out of fear.
Preconnect and DNS prefetch
For domains you know the page will hit (analytics, fonts, CDN, API), tell the browser early:
<link rel="preconnect" href="https://api.example.com" crossorigin />
<link rel="dns-prefetch" href="https://cdn.example.com" />
Saves 100 to 500 ms on the first request to that origin.
Decision 6: Make the rest of the page feel fast
A few moves that do not show up on Lighthouse but real users feel:
- Skeletons over spinners. A grey placeholder shaped like the content feels faster than a centered spinner. The page looks alive.
-
Optimistic UI for mutations. When the user clicks "Like", increase the count immediately. Roll back if the request fails. (See
useOptimisticin React 19.) -
Prefetch on hover or intent. Next.js's
<Link>does this automatically. For other apps, usefetchpriority="low"on speculative requests, or a library like Quicklink. -
Defer below the fold. Anything not visible can wait.
<img loading="lazy">,<iframe loading="lazy">,IntersectionObserverfor components. -
Avoid
display: nonefor things you will show in 50ms. Build them off screen, animate in. The user perceives the action faster.
Decision 7: A practical performance budget
A budget is a number you commit to and let your CI enforce. Without one, performance rots over time.
A starting budget for most apps:
| Asset | Budget |
|---|---|
| Main bundle JS | < 100 KB gzipped on the entry route |
| CSS | < 50 KB gzipped |
| Images on first paint | < 200 KB total |
| Fonts | 1 to 2 weights, woff2, < 100 KB |
| Total page weight | < 500 KB on first paint |
| LCP | < 2.5 s on a slow 4G mid range phone |
| INP | < 200 ms |
| CLS | < 0.1 |
Plug a tool like lighthouse-ci, bundlesize, or size-limit into CI. If a PR pushes the bundle past the budget, the build fails. The PR explains itself.
Decision 8: Devtools you should know
- Lighthouse panel for one-off audits.
- Performance panel to record a real interaction and see where time goes. The flame graph is your best friend.
- Network panel with throttling. Set "Fast 3G", reload, watch what happens.
- Coverage panel to find unused JS and CSS.
- WebPageTest (webpagetest.org) for a deeper, scriptable analysis. The waterfall view is iconic.
-
bundle-analyzerplugins for Vite, Webpack, and Next.js. See what is in your bundle, by file, sorted by size. -
web-vitalsextension to see real metrics in the browser bar as you click around.
A peek under the hood
What really happens between the click and the pixels:
- DNS lookup for the domain.
- TCP / TLS handshake with the server.
- HTTP request for the HTML.
- HTML streams to the browser. The parser starts immediately.
- CSS in the head blocks rendering until it parses. This is why critical CSS matters.
-
<script>tags withoutdefer/asyncblock the parser. This is why script placement matters. - Layout computes the size and position of every element.
- Paint fills in pixels.
- Composite stacks layers and shows the final frame.
- JavaScript hydrates any framework on the page, attaching event listeners.
Two practical consequences:
- Anything that delays steps 4 to 8 hurts LCP. Fonts, images, render blocking CSS, server time.
- Anything that runs on the main thread after step 10 hurts INP. Heavy hydration, large JS bundles, third party scripts.
That mental model is enough to debug almost any performance issue you will hit.
Tiny tips that will save you later
- Test on a real low end phone. Your MacBook does not represent your users.
- Throttle to slow 4G in DevTools before believing your local times.
- Set
widthandheighton every image and iframe. -
Use
loading="lazy"on images below the fold. - Self host fonts. Preload one. Use
font-display: swap. - Code split by route.
-
Remove dependencies you do not use.
npx depcheckfinds them. - Audit third party scripts. Analytics, tag managers, chat widgets are often the slowest thing on a page.
- Run Lighthouse in CI. Performance regresses silently otherwise.
- Track INP in production. Most regressions live in JavaScript work, not in the network.
-
Cache HTML for short windows with
stale-while-revalidateto absorb traffic spikes without losing freshness.
Wrapping up
So that is the whole story. We were tired of building beautiful sites that felt slow. We learned that the user only cares about three things: did the page show up, does it respond when I tap, is it staying still. Google bottled those into LCP, INP, and CLS. We measured with Lighthouse, CrUX, and web-vitals. We fixed LCP with preloading, modern image formats, server rendering, and font tactics. We fixed INP by yielding to the main thread, splitting bundles, cutting work, and moving heavy compute to workers. We fixed CLS with image dimensions, font metric overrides, and reserved space.
We taught our network to compress, cache, preconnect, and prefetch. We set a budget, plugged it into CI, and stopped letting bundle size sneak upward.
Once that map is in your head, web performance stops feeling like a dark art and starts feeling like a small set of repeatable habits. You ship fast pages on purpose, not by accident.
Happy optimizing, and may your Vitals always be green.
Top comments (0)