DEV Community

Mohamed Idris
Mohamed Idris

Posted on

Learning Web Performance As If You Built It Yourself

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);
Enter fullscreen mode Exit fullscreen mode

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"
/>
Enter fullscreen mode Exit fullscreen mode

In Next.js, priority on <Image> does the same thing:

<Image src="/hero-bakery.jpg" alt="..." width={1200} height={800} priority />
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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="..."
/>
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.lazy plus 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-react is fine. Importing the whole namespace is not.
  • Replace heavy dependencies. date-fns/locale/en over moment. nanoid over uuid.
  • 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.memo on heavy components passed stable props.
  • useMemo for expensive computations.
  • useCallback only 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" />
Enter fullscreen mode Exit fullscreen mode

Or in CSS:

img { aspect-ratio: 4 / 3; height: auto; }
Enter fullscreen mode Exit fullscreen mode

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: optional to skip the swap entirely on slow connections.
  • size-adjust, ascent-override, descent-override in @font-face to make the fallback look like the custom font.
  • The next/font package 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; }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

HTML responses should be revalidated each time:

Cache-Control: no-cache
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" />
Enter fullscreen mode Exit fullscreen mode

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 useOptimistic in React 19.)
  • Prefetch on hover or intent. Next.js's <Link> does this automatically. For other apps, use fetchpriority="low" on speculative requests, or a library like Quicklink.
  • Defer below the fold. Anything not visible can wait. <img loading="lazy">, <iframe loading="lazy">, IntersectionObserver for components.
  • Avoid display: none for 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-analyzer plugins for Vite, Webpack, and Next.js. See what is in your bundle, by file, sorted by size.
  • web-vitals extension 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:

  1. DNS lookup for the domain.
  2. TCP / TLS handshake with the server.
  3. HTTP request for the HTML.
  4. HTML streams to the browser. The parser starts immediately.
  5. CSS in the head blocks rendering until it parses. This is why critical CSS matters.
  6. <script> tags without defer/async block the parser. This is why script placement matters.
  7. Layout computes the size and position of every element.
  8. Paint fills in pixels.
  9. Composite stacks layers and shows the final frame.
  10. 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 width and height on 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 depcheck finds 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-revalidate to 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)