March 10, 2026 · Tommy Bordas

Web performance audit, my 5-step method and the systematic quick wins

performanceauditcore-web-vitalsquick-winsseo

An effective web performance audit isn't a Lighthouse score. It's a 5-step method: measure the real world, profile, prioritize by impact, apply the quick wins, then monitor your Core Web Vitals. Here's how I work, and the fixes that pay off almost every time.

Why a method, not a Lighthouse score

Lighthouse gives a lab score on a powerful machine, wired network, no cold cache and no extensions. Your users, meanwhile, are on a mid-range phone, on 4G, with a CPU three to four times slower and a battery that throttles. Two different worlds. A serious audit always starts from field data before touching the code: that's what Google measures for ranking, through the CrUX report (Chrome User Experience Report).

The gap isn't cosmetic. A page can score 95 in the lab and still ship a field LCP of 4.5 s at the 75th percentile, exactly the threshold that flags it "needs improvement" in Search Console. The score is a diagnostic signal; field data is the truth.

Criterion Lab (synthetic) Field (RUM)
Source Lighthouse, WebPageTest, PageSpeed CrUX, the web-vitals library, in-house RUM
Conditions Controlled, reproducible Real devices, real networks
INP metric Estimated / simulated Measured on real interactions
Best for Debugging, comparing before/after Deciding what to prioritize, tracking ranking
Limitation Doesn't see your real users Noisy, needs traffic volume

Core Web Vitals thresholds as a reminder ("good" at the 75th percentile): LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1. INP replaced FID in March 2024. If your monitoring still talks about FID, it's out of date.

Takeaway: optimize what your users experience, not what your dev machine sees. The Lighthouse score is a starting point for debugging, never the end goal.

Step 1: Measure the real world (web-vitals + sendBeacon)

Collect Core Web Vitals in the field with the official web-vitals library, and send them to an endpoint with sendBeacon (which survives the tab closing, unlike a regular fetch). Key point: for LCP, CLS and INP the value is only final when the page is unloaded. So you report inside the callback and never try to read the metric "right away".

import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

const report = (metric) => {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,        // 'good' | 'needs-improvement' | 'poor'
    id: metric.id,
    navigationType: metric.navigationType,
    path: location.pathname,
  });
  // sendBeacon survives navigation; fall back to fetch keepalive
  (navigator.sendBeacon && navigator.sendBeacon('/vitals', body)) ||
    fetch('/vitals', { body, method: 'POST', keepalive: true });
};

onLCP(report);
onINP(report);
onCLS(report);
onTTFB(report);
onFCP(report);

A few rules I hold myself to here:

  • Segment by page and by device. A global average hides everything. The home page's LCP has nothing to do with a product page's.
  • Reason at the 75th percentile, never the mean. That's Google's metric, and an average gets crushed by the good cases.
  • Attribute. web-vitals/attribution tells you which element is the LCP or which interaction produced the worst INP. That's what turns a measurement into an actionable lead.

Not enough traffic for in-house RUM yet? Start from CrUX data (via PageSpeed Insights or the CrUX API) and switch to RUM as soon as the project justifies it.

Step 2: Profile to find the bottlenecks

Measuring tells you what; profiling tells you why. Open the DevTools Performance tab, enable CPU 4× slowdown throttling and "Slow 4G" network, then record a cold load (cleared cache, incognito to neutralize extensions). Look for the three usual suspects.

Symptom Common cause Where to see it in DevTools
Slow LCP Unoptimized hero image, blocking CSS/JS, high server TTFB Timings + LCP in Performance, Network waterfall
High INP JS long tasks, heavy event handlers, third-party hydration Main thread, red "Long Task" blocks (> 50 ms)
Visible CLS Images/iframes without dimensions, fonts (FOIT/FOUT), injected banners Layout Shift Regions overlay, Experience track
Slow TTFB Slow backend, no caching, redirect chains First bar of the Network waterfall

The markers I concretely hunt for:

  • Long tasks: any main-thread task over 50 ms blocks interactions. The red-bordered blocks in the timeline are your first candidates for splitting (yield, requestIdleCallback, web workers).
  • Render-blocking resources: a synchronous <script> in the <head> or a bulky stylesheet delays rendering. The network waterfall shows exactly what's holding back the first paint.
  • Layout shifts: the "Layout Shift Regions" overlay (⋮ menu → More tools → Rendering) flashes shifting areas in blue. Nine times out of ten: an image without width/height or a web font pushing text around.

Takeaway: never profile on your machine under nominal conditions. CPU 4×, Slow 4G, cleared cache: otherwise you're optimizing a problem your users don't have and missing the one they do.

Step 3: Prioritize by impact, not by ease

Not all optimizations are equal. I plot each lead on an impact × effort matrix and attack the "high impact / low effort" quadrant first. That's what makes an audit deliver gains in days, not quarters.

Impact ↑
  │  Big projects       │  DO this         │
  │  (schedule these:   │  first           │
  │   SSR rewrite,      │  (quick wins:    │
  │   image pipeline)   │   AVIF, preload) │
  ├─────────────────────┼──────────────────┤
  │  Ignore             │  Bonus if time   │
  │  (micro-gains)      │  (polish)        │
  └─────────────────────┴──────────────────→ Effort

To decide, I tie each lead to the metric it moves: a hero image in AVIF + fetchpriority acts on LCP; splitting a long task or deferring a third-party script acts on INP; sizing media acts on CLS. If an optimization touches no Core Web Vital and no TTFB, it sinks down the stack.

Step 4: The systematic quick wins

These fixes show up on almost every audit and offer the best impact/effort ratio. I apply them in this order.

Images: the first lever for LCP

The hero image is the LCP element on most pages. Serve it as AVIF (with WebP as fallback), with explicit width/height (anti-CLS), fetchpriority="high" so it ships ahead of everything else, and never loading="lazy" on it. Lazy-loading is reserved for everything below the fold.

<!-- LCP image: prioritized, sized, AVIF + fallback -->
<picture>
  <source srcset="/img/hero.avif" type="image/avif">
  <source srcset="/img/hero.webp" type="image/webp">
  <img src="/img/hero.jpg" width="1200" height="600"
       fetchpriority="high" decoding="async"
       alt="Useful description for accessibility and SEO">
</picture>

<!-- Off-viewport image: deferred -->
<img src="/img/block-3.webp" width="800" height="500"
     loading="lazy" decoding="async" alt="…">

Fonts: kill FOIT and stabilize the layout

<!-- Preload the critical font (the only one in the first render) -->
<link rel="preload" href="/fonts/inter-subset.woff2" as="font"
      type="font/woff2" crossorigin>
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-subset.woff2') format('woff2');
  font-display: swap;     /* text shows immediately */
  font-weight: 400 700;   /* one variable font covers several weights */
}

Three moves: font-display: swap (text shows in the system font then swaps in, no more FOIT); preload the single first-render font; subsetting to load only the glyphs you use (latin-1 instead of the full alphabet often cuts the weight by 5×). Anti-CLS bonus: align the fallback's size-adjust/ascent-override with the web font to avoid the text jump on swap.

JavaScript: less, later, on demand

  • defer (or type="module") on all non-critical JS: it no longer blocks rendering.
  • Dead code: a build with tree-shaking plus a DevTools coverage pass reveals the never-executed kilobytes. On WordPress, that often means disabling a plugin's scripts on pages where it isn't used.
  • Third parties on interaction: chat, maps, players, social widgets… load them on the first scroll, click or hover, not on load. The "facade" pattern (a clickable image that hydrates the real widget on demand) is unbeatable for YouTube/Maps embeds.

Cache, compression, CDN: the server-side win

  • Brotli (or Gzip as a fallback) on HTML/CSS/JS: -15 to -25% transferred weight vs uncompressed.
  • Cache-Control: public, max-age=31536000, immutable on versioned assets (hash in the filename), so a returning visitor re-downloads nothing.
  • CDN to move bytes closer to the user and crush TTFB for distant audiences.

Step 5: Monitor over time (performance budget in CI)

An optimization that isn't watched regresses. A plugin added, an image forgotten, a marketing script pasted into prod, and LCP climbs again. Set a performance budget in CI and block any PR that exceeds it. With Lighthouse CI, that's a single file.

{
  "ci": {
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "interaction-to-next-paint": ["error", { "maxNumericValue": 200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-byte-weight": ["warn", { "maxNumericValue": 1600000 }],
        "unused-javascript": ["warn", { "maxNumericValue": 100000 }]
      }
    }
  }
}

In parallel, keep an eye on the field data (Search Console → Core Web Vitals, or a dashboard fed by the /vitals endpoint from Step 1). The lab catches regressions before merge; the field confirms the gains hold up for real users.

The complete audit checklist

Measurement & diagnosis

  • Collect field Core Web Vitals (web-vitals + sendBeacon, or CrUX)
  • Reason at the 75th percentile, segmented by page and device
  • Profile cold in realistic mobile conditions (CPU 4×, Slow 4G, cleared cache)
  • Identify long tasks, render-blocking resources and layout shifts
  • Rank each lead on the impact × effort matrix

Quick wins

  • LCP image as AVIF/WebP, sized, fetchpriority="high", never lazy
  • loading="lazy" on all off-viewport media
  • Fonts: font-display: swap, preload the critical one, subsetting
  • JS: defer, dead-code removal, third parties loaded on interaction
  • Brotli + Cache-Control immutable on versioned assets + CDN

Durability

  • Performance budget in CI (Lighthouse CI) that blocks regressions
  • Continuous field-data tracking (Search Console / RUM dashboard)
  • Re-measure and compare before/after on every deployment

Going further

For a concrete front-end case, with real numbers, see: How I optimized an Angular application's performance by 40%.

Want a full web performance audit (field data, DevTools profiling, a roadmap prioritized on the impact × effort matrix and quick wins you can ship right away)? That's exactly the work I run on every engagement, WordPress/WooCommerce and Angular alike. Let's talk about your site and your Core Web Vitals.