Lighthouse 95+ in Every Category
Overview
Never optimize blind. The loop is: measure the real URL → identify the exact element/cause in the JSON → fix that one cause → redeploy → re-measure. Guessing burns rounds; one identified cause is worth ten plausible optimizations.
Iron Rules
- Measure the URL users actually hit. Any host with preview/prod aliases (Vercel, Netlify, Cloudflare Pages): a plain deploy creates a preview — the public alias still serves the OLD production until you promote it (on Vercel:
--prod). Measuring the stale deploy wastes a full round (this happens silently: scores just "don't move").
- No fix before identifying the cause. Extract the LCP element and its phase breakdown from the JSON before touching anything.
- Verdict = 3 consecutive runs. Throttled runs vary ±0.5s / ±4 points. One run proves nothing.
Mobile AND Desktop
Lighthouse defaults to MOBILE emulation (mid-tier phone, slow 4G, 4x CPU throttle) — the hard mode, and what Google uses for Core Web Vitals. Desktop scores run 10–20 points higher. Measure both, optimize against the MOBILE run (fix mobile → desktop follows; the reverse is false), and never compare runs across presets.
Measure & Diagnose
# Mobile (default emulation)
npx lighthouse https://SITE --output=json --output-path=./lh-mobile.json --quiet \
--chrome-flags="--headless=new" \
--only-categories=performance,accessibility,best-practices,seo
# Desktop (confirm after mobile passes)
npx lighthouse https://SITE --preset=desktop --output=json --output-path=./lh-desktop.json --quiet \
--chrome-flags="--headless=new" \
--only-categories=performance,accessibility,best-practices,seo
Then interrogate the JSON — never just read the scores:
node -e "
const r = require('./lh-mobile.json');
for (const [k,v] of Object.entries(r.categories)) console.log(k, Math.round(v.score*100));
// LCP element + phases (audit id varies by LH version):
const lcp = r.audits['lcp-breakdown-insight'] || r.audits['largest-contentful-paint-element'];
console.log(JSON.stringify(lcp?.details?.items, null, 1));
// TBT culprits:
r.audits['bootup-time']?.details?.items?.slice(0,5)
.forEach(i => console.log('JS:', i.url.split('/').pop(), Math.round(i.total)+'ms'));
// a11y failures with exact selectors:
Object.values(r.audits).filter(a => a.score !== null && a.score < 1 &&
r.categories.accessibility.auditRefs.some(x => x.id === a.id))
.forEach(a => console.log('A11Y:', a.id, a.details?.items?.[0]?.node?.selector || ''));
"
Fix Playbook (by identified cause)
| Cause (from JSON) |
Fix |
LCP is TEXT with high elementRenderDelay |
It's waiting for a web font swap. Self-host, then instance + subset the font (below). A 129KB variable font became 21KB and moved LCP 2.9s → 1.7s. |
| LCP is IMAGE |
<link rel="preload" as="image" fetchpriority="high"> + explicit width/height + compress (sharp/mozjpeg, resize to display size). |
| High TBT from a JS lib (bootup-time) |
Load it AFTER window load via injected script tags. Animation libs (GSAP etc.): invert visibility — CSS never hides content; JS hides only below-fold elements right before animating them. LCP, no-JS, and reduced-motion stay safe, and 1.2s of TBT becomes ~0. |
| FCP high, same-origin small page |
Inline the critical CSS into <head> (kills the stylesheet round-trip). Trim preloads competing with the LCP resource. |
| Fonts from Google/CDN |
Self-host woff2 (latin subset), font-display: swap, preload only slim critical files. Zero external origins is the end state. |
a11y color-contrast |
Check CSS specificity bugs, not just palette: a .nav a { color } rule can override .btn-primary and paint low-contrast text nobody intended. |
a11y landmark-one-main |
Wrap page content in <main>. |
| SEO/BP stragglers |
Canonical link, meta description, theme-color, explicit image dimensions, HTTPS. |
Font Instancing + Subsetting (the usual killer move)
# pip install fonttools brotli
from fontTools.ttLib import TTFont
from fontTools.varLib.instancer import instantiateVariableFont
from fontTools.subset import Subsetter, Options
f = TTFont("font-variable.woff2")
if "fvar" in f: # pin variable axes to the ONE weight you actually use
instantiateVariableFont(f, {"wght": 600, "opsz": 48}, inplace=True)
opt = Options(); opt.flavor = "woff2"; opt.layout_features = ["*"]
s = Subsetter(opt)
s.populate(unicodes=range(0x20, 0x17F)) # latin + latin-ext; add smart quotes U+2013-2026
s.subset(f); f.flavor = "woff2"; f.save("font-600.woff2")
Update @font-face to font-weight: 600 and fix any usage relying on other weights.
Traps (each cost a real round)
| Trap |
Reality |
| "Scores didn't move — my fix failed" |
Check you measured the NEW deploy (preview vs production alias). |
| "Preloads are good, add them all" |
A 129KB font preload starves the LCP image on 4G. Preload only what LCP needs; slim files first. |
| "Optimize images first, it's always images" |
Today's LCP was an H1 waiting on a font. Identify the element first. |
| "One run at 95 = done" |
Variance. Three consecutive runs ≥95, all categories, or keep going. |
| "Hide content in CSS, reveal with JS animations" |
JS loads late/never → invisible page, failed LCP. Content visible by default; JS only hides what it will animate. |
When NOT to Use
- Field data (CrUX / real-user metrics) — Lighthouse is a lab tool. If the problem is "Search Console says CWV failing", real-user conditions differ; lab 95+ does not guarantee a field pass.
- localhost / dev mode — dev bundles are unminified and unoptimized; scores are meaningless. Only measure deployed, production-built URLs.
- SPAs measured only on the shell route — measure the routes users actually land on.
Verification Gate
Done means: 3 consecutive runs ≥95 in all four categories on MOBILE, plus one desktop run ≥95 confirming — production URL. Report both metric tables (FCP/LCP/TBT/CLS) before/after, not just scores.
1---2name: lighthouse-953description: Use when a website must score 95+ in every Lighthouse category (Performance, Accessibility, Best Practices, SEO), when scores are stuck below target, or when diagnosing poor FCP, LCP, TBT, or CLS on a deployed site.4---56# Lighthouse 95+ in Every Category78## Overview910Never optimize blind. The loop is: **measure the real URL → identify the exact element/cause in the JSON → fix that one cause → redeploy → re-measure**. Guessing burns rounds; one identified cause is worth ten plausible optimizations.1112## Iron Rules13141. **Measure the URL users actually hit.** Any host with preview/prod aliases (Vercel, Netlify, Cloudflare Pages): a plain deploy creates a *preview* — the public alias still serves the OLD production until you promote it (on Vercel: `--prod`). Measuring the stale deploy wastes a full round (this happens silently: scores just "don't move").152. **No fix before identifying the cause.** Extract the LCP element and its phase breakdown from the JSON before touching anything.163. **Verdict = 3 consecutive runs.** Throttled runs vary ±0.5s / ±4 points. One run proves nothing.1718## Mobile AND Desktop1920Lighthouse defaults to **MOBILE emulation** (mid-tier phone, slow 4G, 4x CPU throttle) — the hard mode, and what Google uses for Core Web Vitals. Desktop scores run 10–20 points higher. Measure **both**, optimize against the MOBILE run (fix mobile → desktop follows; the reverse is false), and never compare runs across presets.2122## Measure & Diagnose2324```bash25# Mobile (default emulation)26npx lighthouse https://SITE --output=json --output-path=./lh-mobile.json --quiet \27 --chrome-flags="--headless=new" \28 --only-categories=performance,accessibility,best-practices,seo2930# Desktop (confirm after mobile passes)31npx lighthouse https://SITE --preset=desktop --output=json --output-path=./lh-desktop.json --quiet \32 --chrome-flags="--headless=new" \33 --only-categories=performance,accessibility,best-practices,seo34```3536Then interrogate the JSON — never just read the scores:3738```bash39node -e "40const r = require('./lh-mobile.json');41for (const [k,v] of Object.entries(r.categories)) console.log(k, Math.round(v.score*100));42// LCP element + phases (audit id varies by LH version):43const lcp = r.audits['lcp-breakdown-insight'] || r.audits['largest-contentful-paint-element'];44console.log(JSON.stringify(lcp?.details?.items, null, 1));45// TBT culprits:46r.audits['bootup-time']?.details?.items?.slice(0,5)47 .forEach(i => console.log('JS:', i.url.split('/').pop(), Math.round(i.total)+'ms'));48// a11y failures with exact selectors:49Object.values(r.audits).filter(a => a.score !== null && a.score < 1 &&50 r.categories.accessibility.auditRefs.some(x => x.id === a.id))51 .forEach(a => console.log('A11Y:', a.id, a.details?.items?.[0]?.node?.selector || ''));52"53```5455## Fix Playbook (by identified cause)5657| Cause (from JSON) | Fix |58|---|---|59| LCP is TEXT with high `elementRenderDelay` | It's waiting for a web font swap. Self-host, then **instance + subset** the font (below). A 129KB variable font became 21KB and moved LCP 2.9s → 1.7s. |60| LCP is IMAGE | `<link rel="preload" as="image" fetchpriority="high">` + explicit `width`/`height` + compress (sharp/mozjpeg, resize to display size). |61| High TBT from a JS lib (bootup-time) | Load it AFTER `window` `load` via injected script tags. Animation libs (GSAP etc.): invert visibility — **CSS never hides content; JS hides only below-fold elements right before animating them**. LCP, no-JS, and reduced-motion stay safe, and 1.2s of TBT becomes ~0. |62| FCP high, same-origin small page | Inline the critical CSS into `<head>` (kills the stylesheet round-trip). Trim preloads competing with the LCP resource. |63| Fonts from Google/CDN | Self-host woff2 (latin subset), `font-display: swap`, preload only slim critical files. Zero external origins is the end state. |64| a11y `color-contrast` | Check **CSS specificity bugs**, not just palette: a `.nav a { color }` rule can override `.btn-primary` and paint low-contrast text nobody intended. |65| a11y `landmark-one-main` | Wrap page content in `<main>`. |66| SEO/BP stragglers | Canonical link, meta description, `theme-color`, explicit image dimensions, HTTPS. |6768## Font Instancing + Subsetting (the usual killer move)6970```python71# pip install fonttools brotli72from fontTools.ttLib import TTFont73from fontTools.varLib.instancer import instantiateVariableFont74from fontTools.subset import Subsetter, Options7576f = TTFont("font-variable.woff2")77if "fvar" in f: # pin variable axes to the ONE weight you actually use78 instantiateVariableFont(f, {"wght": 600, "opsz": 48}, inplace=True)79opt = Options(); opt.flavor = "woff2"; opt.layout_features = ["*"]80s = Subsetter(opt)81s.populate(unicodes=range(0x20, 0x17F)) # latin + latin-ext; add smart quotes U+2013-202682s.subset(f); f.flavor = "woff2"; f.save("font-600.woff2")83```8485Update `@font-face` to `font-weight: 600` and fix any usage relying on other weights.8687## Traps (each cost a real round)8889| Trap | Reality |90|---|---|91| "Scores didn't move — my fix failed" | Check you measured the NEW deploy (preview vs production alias). |92| "Preloads are good, add them all" | A 129KB font preload starves the LCP image on 4G. Preload only what LCP needs; slim files first. |93| "Optimize images first, it's always images" | Today's LCP was an H1 waiting on a font. Identify the element first. |94| "One run at 95 = done" | Variance. Three consecutive runs ≥95, all categories, or keep going. |95| "Hide content in CSS, reveal with JS animations" | JS loads late/never → invisible page, failed LCP. Content visible by default; JS only hides what it will animate. |9697## When NOT to Use9899- **Field data (CrUX / real-user metrics)** — Lighthouse is a *lab* tool. If the problem is "Search Console says CWV failing", real-user conditions differ; lab 95+ does not guarantee a field pass.100- **localhost / dev mode** — dev bundles are unminified and unoptimized; scores are meaningless. Only measure deployed, production-built URLs.101- **SPAs measured only on the shell route** — measure the routes users actually land on.102103## Verification Gate104105Done means: **3 consecutive runs ≥95 in all four categories on MOBILE, plus one desktop run ≥95 confirming — production URL.** Report both metric tables (FCP/LCP/TBT/CLS) before/after, not just scores.