Web Performance: What to Measure and How to Fix It


Web performance has a measurement problem. Developers optimize for metrics that don’t match user experience, add tools that claim to improve performance but add kilobytes of JavaScript, and celebrate lighthouse scores on pages that feel slow to actual users.

The right starting point is understanding what browsers actually do to display a page, which user experiences you’re actually trying to improve, and what the tools are measuring.

How Browsers Load Pages

When the browser fetches an HTML document, it begins parsing immediately. As it finds resources - <link> tags for CSS, <script> tags for JavaScript, <img> tags for images - it starts fetching them. This is the critical rendering path.

CSS is render-blocking. The browser won’t paint anything until it has all the CSS that applies to the current page. It can’t know what things should look like without the styles.

JavaScript (by default) is both parser-blocking and render-blocking. When the parser hits a <script> tag, it stops parsing HTML until the script downloads and executes. If the script needs to modify the DOM, it needs to run before rendering. If it doesn’t need to (and most scripts don’t), async or defer attributes tell the browser to keep parsing.

<!-- Blocks HTML parsing until downloaded and executed -->
<script src="app.js"></script>

<!-- Downloads in parallel, executes when ready, doesn't block parsing -->
<script src="analytics.js" async></script>

<!-- Downloads in parallel, executes after HTML is fully parsed -->
<script src="app.js" defer></script>

Images, by default, are not render-blocking. The browser starts downloading them but doesn’t wait for them before painting the page.

Understanding this model lets you reason about what’s slowing your page down.

Core Web Vitals

Google’s Core Web Vitals are field metrics - measured from real user sessions, not lab conditions. They’re what Google uses for search ranking signals and what you should be optimizing for.

Largest Contentful Paint (LCP) - when did the largest visible element (usually the hero image or main heading) finish rendering? Good: under 2.5 seconds. This is the most important metric for perceived load speed.

Interaction to Next Paint (INP) - what’s the latency of your worst interactions? Good: under 200ms. This replaced First Input Delay (FID) and measures responsiveness throughout the page’s lifetime, not just the first interaction.

Cumulative Layout Shift (CLS) - how much do elements jump around during loading? Good: under 0.1. That annoying thing where you go to click a button and it moves because an image loaded above it - that’s layout shift.

Improving LCP

LCP is almost always the most impactful metric to fix. The common causes of slow LCP:

Slow server: if your server takes 2 seconds to respond with HTML, your LCP can’t be under 2 seconds. Time to First Byte (TTFB) feeds directly into LCP. Use a CDN, optimize server-side rendering, or consider static generation for pages that don’t need dynamic content.

Render-blocking resources: if your LCP element (usually a hero image) can’t be displayed until a large CSS file or JavaScript bundle loads, that’s adding directly to LCP. Inline critical CSS (the styles needed for above-the-fold content), defer non-critical CSS, and move scripts to use defer or async.

Slow LCP image: the hero image takes too long to download. Compress it, convert to WebP or AVIF, and use fetchpriority="high" to tell the browser to prioritize it.

<!-- Tell the browser this image is critical for LCP -->
<img src="hero.webp" alt="..." fetchpriority="high" width="800" height="400">

Lazy loading the LCP image: a common mistake. loading="lazy" tells the browser to defer loading images that are below the fold. Never apply this to your LCP image.

Improving CLS

Layout shifts usually come from content that loads asynchronously and displaces other content:

Images without dimensions: if you don’t specify width and height on images, the browser doesn’t know how much space to reserve. When the image loads, it pushes content down.

<!-- Reserves space, prevents layout shift -->
<img src="photo.jpg" width="800" height="600" alt="...">

Web fonts causing FOUT/FOIT: Flash of Unstyled Text (FOUT) or Flash of Invisible Text (FOIT) happens when custom fonts load late. Use font-display: swap in your @font-face rules to show fallback text immediately, then swap to the custom font when it loads. The swap itself can cause a small layout shift; font-display: optional prevents any shift but may not load the font at all for slow connections.

Dynamic content injected above existing content: ads, banners, cookie notices loaded asynchronously that push page content down. Reserve space for dynamic content, or inject it below the fold.

INP and JavaScript Execution

INP measures how responsive the page is to user interactions. The main cause of high INP: long-running JavaScript that blocks the main thread and prevents the browser from responding to user input.

The browser’s rendering, layout, painting, and event handling all happen on the main thread. If your JavaScript runs for 300ms without yielding, any user input during that time is queued and won’t be processed until the script finishes.

Finding long tasks: in Chrome DevTools, the Performance panel highlights “Long Tasks” - tasks that take over 50ms. The Total Blocking Time (TBT) metric in Lighthouse sums the time tasks spend over 50ms.

Fixes:

  • Break up long computations into smaller chunks, yielding to the main thread between them
  • Move heavy work to Web Workers (off the main thread entirely)
  • Reduce JavaScript bundle size - less code means less to parse and execute
  • Avoid layout thrashing (reading and writing DOM layout properties in alternation forces the browser to recalculate layout repeatedly)

Measuring Real User Performance

Lighthouse and WebPageTest measure lab performance - a synthetic test on a known device and network. They’re useful for development feedback but don’t reflect what real users experience.

For real user data:

  • Chrome User Experience Report (CrUX): aggregated real user data for public URLs, accessible via PageSpeed Insights
  • web-vitals JavaScript library: measures Core Web Vitals in your users’ browsers
import {onLCP, onINP, onCLS} from 'web-vitals';

onLCP(metric => sendToAnalytics('lcp', metric.value));
onINP(metric => sendToAnalytics('inp', metric.value));
onCLS(metric => sendToAnalytics('cls', metric.value));

The gap between lab scores and field data is often revealing. A page might score 90 on Lighthouse while real users on slow mobile connections experience something completely different.

What to Measure First, and What Actually Helps

Web performance is a large topic, and it’s easy to spend time on things that don’t move the needle. The most common misprioritization: optimizing images or JavaScript bundles on a page where the server response time is 2 seconds. The image optimization saves 50ms. The server response time is the entire problem.

Start with this order:

1. Time to First Byte (TTFB). If the server takes more than 200-300ms to respond, nothing else matters much. Fix server response time before anything else. Causes: slow database queries, no caching on expensive server-rendered pages, no CDN.

2. LCP - Largest Contentful Paint. Usually the hero image or main heading. Check: is the LCP image getting fetchpriority="high"? Is there render-blocking CSS above the fold? Is TTFB already fast? This one metric has the most visible impact on perceived load speed.

3. CLS - only if you have layout shifts. Measure it; if it’s under 0.1, move on.

4. INP - only if interaction is slow. If the page feels sluggish to clicks or keystrokes, look at long JavaScript tasks. If it feels fine, this is likely not your bottleneck.

The changes that actually move the needle, in rough order of impact:

  • CDN for static assets and server-side rendering
  • Image optimization: correct format (WebP/AVIF), correct size (don’t serve 2000px to a 400px container), fetchpriority="high" for LCP
  • Remove render-blocking resources (defer scripts, inline critical CSS)
  • Reduce JavaScript bundle size (code splitting, removing unused dependencies)

The changes that feel impactful but rarely are: micro-optimizing CSS selectors, obsessing over Lighthouse scores in lab conditions while ignoring field data, minification (already done by every build tool), adding a “performance library” that weighs 50KB.

Measure field data with the web-vitals library. If your p75 LCP is over 2.5s, start with TTFB and image optimization. Everything else is secondary.



Read more