← Back to Blog

The Complete Web Performance Guide: From Core Web Vitals to a Shippable Checklist

Performance Is a Business Metric, Not a Technical One

Let us start with shared ground: performance optimisation is not an engineer's self-improvement exercise. It maps directly onto revenue. The correlations the industry keeps reproducing include:

  • Every extra second of load time measurably drops mobile conversion
  • Walmart measured roughly a 2% conversion lift per second of improvement
  • Pinterest cut perceived wait time by 40% and saw search traffic and sign-ups rise about 15%

The exact numbers vary by industry and by baseline, so do not copy them. The direction is remarkably consistent: faster pages convert better.

Beyond conversion, performance is also an SEO input — Google states plainly that Core Web Vitals are a ranking signal. That moves performance from "nice to have" to "must pass."

For caching and compression response headers, check them entry by entry against our HTTP headers cheat sheet.

Core Web Vitals: Know What Is Being Measured

Metric What it measures Good Needs work Poor
LCP Largest Contentful Paint Loading: when the largest content element renders ≤ 2.5s 2.5–4s > 4s
INP Interaction to Next Paint Responsiveness: delay from user action to painted update ≤ 200ms 200–500ms > 500ms
CLS Cumulative Layout Shift Visual stability: accumulated unexpected layout movement ≤ 0.1 0.1–0.25 > 0.25

Three details matter:

  1. INP officially replaced FID in March 2024. FID only measured the input delay of the first interaction, excluding processing and rendering, so it badly understated real jank. INP measures the full latency of almost all interactions. Any material still talking about FID is out of date.
  2. The 75th percentile is what counts. Not the average — three quarters of real visits must pass. A healthy average is irrelevant if the long tail fails.
  3. All three must pass together. Missing one means "failing" overall.

Lab Data vs Field Data

Source Characteristics Use it for
CrUX / the GSC Core Web Vitals report Real users, 28-day rolling, split by device The only pass/fail verdict
Lighthouse / PageSpeed Insights Lab, fixed conditions, single run Diagnosing problems, listing opportunities
WebPageTest Lab, tunable network/device/location Deep analysis, waterfall inspection

Disagreement between them is normal — see the FAQ at the end.

The Critical Rendering Path: What Actually Holds Up First Paint

Roughly, the browser must: parse HTML → build the DOM → load and parse CSS → build the CSSOM → execute JS → build the render tree → lay out → paint.

Only two kinds of resources block rendering: CSS and synchronous JavaScript.

CSS Must Block — So Minimise Its Scope

CSS blocks rendering for good reason; otherwise you get a flash of unstyled content. The right move is not to remove the blocking but to shrink it:

  • Inline the critical CSS (the minimum needed for first paint) so above-the-fold content depends on no external stylesheet
  • Load non-critical CSS asynchronously:
<!-- Critical CSS inlined -->
<style>/* above-the-fold styles */</style>

<!-- Non-critical CSS, non-blocking -->
<link rel="preload" href="/full.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/full.css"></noscript>

Free JavaScript with defer / async

Attribute When it executes Order guaranteed Use for
None (sync) As soon as downloaded; blocks parsing Document order Tiny scripts that must run first
async As soon as downloaded No Fully independent scripts (analytics, ads)
defer After parsing, before DOMContentLoaded Yes Scripts touching the DOM or each other
type="module" Behaves like defer by default Yes Modern modular code

Default to defer. Reserve async for genuinely independent third-party scripts.

Fonts: Non-Blocking and Jump-Free

Web fonts are a double risk to both LCP and CLS. Do three things together:

<!-- 1. Connect early -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

<!-- 2. Load without blocking: preload first, then use media="print" to dodge the
        render-blocking path, switching to all on load -->
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Inter&display=swap">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter&display=swap"
      media="print" onload="this.media='all'">
/* 3. Match the fallback's metrics to the target font so the swap does not shift text */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 107%;
}

display=swap keeps text readable immediately rather than blank during the wait, and metric matching drives CLS towards zero.

Images: The Single Biggest Win

Images routinely account for over half of a page's total bytes, which makes them the easiest place to find gains. Three dimensions, in order of payoff:

1. Dimensions (biggest payoff, most often ignored)

Dropping a 3000px-wide image into a 300px container is pure waste. Let the browser choose with srcset + sizes:

<img src="photo-800.jpg"
     srcset="photo-400.jpg 400w, photo-800.jpg 800w, photo-1600.jpg 1600w"
     sizes="(max-width: 600px) 100vw, 800px"
     width="800" height="600"
     alt="Description"
     loading="lazy" decoding="async">

To produce multiple sizes, our image resizer and image format converter run entirely in your browser.

2. Format (second biggest)

Format Versus JPEG Alpha Animation Browser support Use for
AVIF ~50% smaller Yes Yes Newer (Safari 16+) Preferred; slow to encode, so batch offline
WebP 25–35% smaller Yes Yes Broad The safe default
JPEG Baseline No No Universal Photo fallback
PNG Larger Yes No Universal Lossless or transparency where SVG does not fit
SVG Tiny Yes Possible Universal Icons, logos, simple graphics

Let the browser choose with <picture> rather than sniffing in JavaScript:

<picture>
  <source type="image/avif" srcset="photo.avif">
  <source type="image/webp" srcset="photo.webp">
  <img src="photo.jpg" width="800" height="600" alt="Description">
</picture>

For a full feature comparison, see the image formats cheat sheet.

3. Loading Timing

  • Do not lazy-load the LCP image — it delays LCP. Use fetchpriority="high" and preload it instead
  • Lazy-load everything below the fold with loading="lazy"
  • Add decoding="async" so decoding does not block the main thread
<!-- LCP element: load with priority -->
<img src="hero.jpg" fetchpriority="high" decoding="async" width="1200" height="600" alt="Hero">

<!-- Below the fold: lazy -->
<img src="item.jpg" loading="lazy" decoding="async" width="300" height="200" alt="List item">

JavaScript and CSS Size

JavaScript: Split Before You Compress

Technique What it does Priority
Route-level splitting Ship only the code this page needs Highest
Drop unused dependencies Use a bundle analyser to spot outsized packages High
Dynamic import Load heavy features (editors, charts, PDF) on demand High
Tree shaking Requires ESM and a correct sideEffects declaration Medium
Minification Most of its benefit is already eaten by gzip/brotli Low

A rule of thumb: be wary once compressed first-screen JS exceeds 300 KB. Check whether an editor, charting library, or PDF parser got bundled into the first screen before reaching for a minifier.

CSS: Deleting Beats Compressing

  • Remove unused CSS (Tailwind, PurgeCSS and similar)
  • Inline critical CSS (above)
  • For normalisation and minification, our CSS formatter and JS formatter handle the code, and JSON minify handles JSON payloads

The Network Layer

Caching Strategy

Resource type Recommended policy Example
Hashed static assets Long-lived, immutable Cache-Control: max-age=31536000, immutable
HTML documents Revalidate every time Cache-Control: no-cache plus ETag
API responses Short, or business-specific Cache-Control: private, max-age=60
Images and media Medium plus revalidation Cache-Control: public, max-age=86400, must-revalidate

The core pattern: put a content hash in the filename (app.a1b2c3.js), so changing content changes the URL and you can safely cache for a year. Never hard-cache the HTML, so a release takes effect immediately.

Compression and Protocols

  • Prefer Brotli, fall back to gzip — Brotli is typically another 15–20% smaller
  • HTTP/2 or HTTP/3 — multiplexing means domain sharding is not merely useless but actively harmful
  • A CDN — push static assets to edge nodes near your users
  • Connect early — preconnect for critical third-party origins, dns-prefetch for secondary ones

CLS: Six Causes and Their Fixes

Cause Typical scenario Fix
Unsized media <img> without width and height Add width/height, or CSS aspect-ratio
Injected content Ads, notice bars, lazy blocks Reserve a fixed-height container or min-height
Font swapping FOUT/FOIT reflows text Preload the font, match metrics with size-adjust, use font-display: optional
Layout-triggering animation Animating top/left/width/height Animate only transform and opacity
Skeleton mismatch Skeleton dimensions differ from real content Make the skeleton match the real layout
Async UI injection Cookie banners, A/B variants Use an overlay (position: fixed) instead of inserting into flow

INP: Break Up Long Tasks

A poor INP almost always means the main thread is held by long tasks, so interactions cannot be handled promptly.

Technique How
Split long tasks Break anything over 50 ms into chunks, yielding with scheduler.yield() or setTimeout
Ship less JS Less first-screen JS means faster parse and execute
Defer third parties Load analytics, ads, and chat widgets on demand or when idle
Avoid layout thrashing Batch DOM reads and writes; never interleave them in a loop
Use a Web Worker Move pure computation off the main thread
Keep the DOM lean More nodes means slower style recalculation and layout
// Break a long loop into chunks that yield to the main thread
async function processAll(items) {
  for (let i = 0; i < items.length; i++) {
    process(items[i]);
    if (i % 50 === 0) await scheduler.yield();  // let the browser handle input
  }
}

Measurement Tools

Tool Data type Best for
GSC Core Web Vitals report CrUX field data Deciding pass/fail
PageSpeed Insights Both One-stop view: field data plus lab suggestions
Lighthouse (DevTools) Lab Local iteration, specific opportunities
WebPageTest Lab, tunable Waterfalls, multiple locations, throttling
CrUX Dashboard / BigQuery Field, segmentable Long-term trends and segmentation

Pre-Launch Checklist

  • [ ] LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1 (CrUX 75th percentile, not a Lighthouse score)
  • [ ] Every <img> / <video> / <iframe> has width and height (or aspect-ratio)
  • [ ] The LCP element is not lazy-loaded and carries fetchpriority="high"
  • [ ] All below-the-fold images use loading="lazy"
  • [ ] Images served as AVIF/WebP with a JPEG/PNG fallback
  • [ ] Critical CSS inlined, non-critical CSS loaded without blocking
  • [ ] JS defaults to defer; third-party scripts async or deferred
  • [ ] Fonts load without blocking with display=swap, and fallbacks are metric-matched
  • [ ] Static assets are hashed with max-age=31536000, immutable
  • [ ] Brotli or gzip enabled
  • [ ] Compressed first-screen JS kept under 300 KB
  • [ ] No long task over 50 ms holding the main thread

Related Resources

For images, our image compressor, image format converter, and image resizer all run locally in the browser with nothing uploaded. For format characteristics see the image formats cheat sheet, and for caching and compression headers see the HTTP headers cheat sheet.

Advertisement

Frequently Asked Questions

What do the three Core Web Vitals measure, and what are the thresholds?

**LCP (Largest Contentful Paint)** measures loading speed, with a good score of 2.5 seconds or less. **INP (Interaction to Next Paint)** measures responsiveness, with a good score of 200 ms or less — it officially replaced FID in March 2024. **CLS (Cumulative Layout Shift)** measures visual stability, with a good score of 0.1 or less. All three are evaluated at the **75th percentile**, meaning three quarters of real user visits must pass, and all three must pass together. These come from real user data (CrUX), not a Lighthouse lab run.

My CLS won't come down. What are the most common causes?

In order of frequency: **1. images, videos, or iframes without width and height** — the browser cannot reserve space, so content gets pushed down once they load; this is the number one cause. 2. Ads, embeds, or lazy-loaded blocks injected after render. 3. Web fonts causing text reflow (FOUT/FOIT). 4. Banners or notification bars inserted above existing content. 5. Animations using layout-triggering properties such as top, left, or width. Fix in this order: give every media element explicit dimensions or `aspect-ratio`, then reserve space for dynamically injected regions, then handle fonts with `font-display: optional` or preloading.

Should I use WebP or AVIF for images?

**Prefer AVIF, fall back to WebP, then to JPEG/PNG.** At equivalent visual quality, AVIF is typically around 50% smaller than JPEG and WebP 25–35% smaller. The tradeoff for AVIF is slower encoding — fine for offline batch processing, not for real-time generation — and slightly later browser support (Safari 16+). Do it properly with `<picture>` and let the browser choose rather than sniffing with JavaScript: `&lt;source type=&quot;image/avif&quot;&gt;` → `&lt;source type=&quot;image/webp&quot;&gt;` → `&lt;img&gt;` as the fallback. **Do not ignore dimensions**: dropping a 3000px image into a 300px container wastes everything the format gained you, so a responsive `srcset` usually delivers a bigger win than switching formats.

My Lighthouse score is high, so why does CrUX still show a failure?

Because they measure different things. **Lighthouse is lab data**: one run under fixed network and device conditions, stable and reproducible, good for diagnosing problems. **CrUX is field data**: it reflects real devices, real networks, and real interaction paths, and it is what Google uses for ranking. Typical sources of the gap: a large share of real users on low-to-mid-range Android phones (3–5x slower CPU), high mobile network latency, and the fact that real users scroll and click, triggering lazy loading and interactions Lighthouse never performs. **Judge pass/fail on CrUX alone**, and use Lighthouse to find out why.

← Back to Blog