A structured, batch-wise audit-and-fix workflow for all four Lighthouse pillars. Always follow the batch flow in order. Never jump straight to fixes without completing the scan and risk assessment phases.
PHASE 1 → Ingest Report & Parse Scores
PHASE 2 → Batch Scan (4 sections, parallel analysis)
PHASE 3 → Consolidated Risk Report (changes ranked by impact vs risk)
PHASE 4 → Fix Batches (applied in safe order: low-risk → high-risk)
PHASE 5 → Verification Checklist
When the user provides a PageSpeed Insights report (pasted text, screenshot, or URL):
| Pillar | Score | Status | Critical Issue |
|-----------------|-------|---------|-------------------------------------|
| Performance | 80 | ⚠️ Warn | LCP 4.0s — element render delay |
| Accessibility | 100 | ✅ Pass | — |
| Best Practices | 100 | ✅ Pass | CSP missing (unscored) |
| SEO | 100 | ✅ Pass | — |
Then proceed immediately to Phase 2 without waiting for user input unless the report is ambiguous.
Run all four section scans. Present as collapsible sections in output.
Audit these in order (highest Lighthouse weight first):
| Audit | Metric Impact | Key Questions |
|---|---|---|
| LCP breakdown | LCP | Is the LCP element lazily loaded? Is TTFB > 600ms? Is element render delay > 1s? |
| Render-blocking resources | FCP, LCP | Which CSS/JS files block the critical path? Can they be deferred or inlined? |
CSS @import rules |
FCP, LCP | Are external stylesheets loaded via @import url() in CSS? This is 2x render-blocking — browser must fetch CSS, parse it, then fetch imported CSS. Use <link> instead. |
| Unused JavaScript | FCP, LCP, TBT | What % of the main bundle is unused? Is code-splitting possible? |
| Network dependency tree | LCP | What is the critical path chain? Max latency? |
| Forced reflows | TBT | Which JS functions query geometry after DOM mutation? |
| Image delivery | FCP, LCP | Are images in WebP/AVIF? Are above-fold images lazy-loaded? |
| Speed Index | SI | Is page visually progressive or does it paint all at once? |
| CLS culprits | CLS | Any images without width/height? Any late-injected content? |
| JavaScript execution time | TBT | Total parse + compile + evaluate time? |
| Long main-thread tasks | TBT | Tasks > 50ms? Starting when? |
| Bundled asset sizes | FCP, LCP, TBT | Check dist/ output: any single JS chunk > 500KB gzipped? CSS > 100KB? Code-splitting creating proper vendor chunks? |
For each audit item, output:
Focus on any failed audits. For a 100-score page, still check:
| Check | What to Verify |
|---|---|
| ARIA attribute correctness | All aria-* attributes match element roles |
| Colour contrast | All text meets WCAG AA (4.5:1 normal, 3:1 large) |
| Image alt text quality | Alt text is descriptive, not filename-style |
| Keyboard navigation | All interactive elements reachable by Tab |
| Skip links | Present and focusable |
| Heading hierarchy | No skipped levels (h1 → h2 → h3) |
| Touch target size | Min 44×44px on mobile |
| Form labels | Every input has an associated label |
lang attribute |
<html lang="en"> present and valid BCP 47 |
font-display |
Set to swap or optional to prevent FOIT |
Security headers are often unflagged by Lighthouse score but are critical. Check ALL deployment targets:
| Check | Header/Setting | Where to Configure | Severity |
|---|---|---|---|
| Content Security Policy | Content-Security-Policy |
netlify.toml [[headers]] / vercel.json "headers" |
🔴 High |
| Cross-Origin-Opener-Policy | COOP header |
Same as above | 🔴 High |
| Clickjacking protection | X-Frame-Options or CSP frame-ancestors |
Same as above | 🔴 High |
| HSTS configuration | Strict-Transport-Security with includeSubDomains + preload |
Same as above | 🟡 Medium |
| Trusted Types (DOM XSS) | CSP require-trusted-types-for 'script' |
Same as above | 🟡 Medium |
| X-Content-Type-Options | nosniff header |
Same as above | 🟡 Medium |
| Referrer-Policy | strict-origin-when-cross-origin |
Same as above | 🟡 Medium |
| Permissions-Policy | Restrict camera/mic/geolocation | Same as above | 🟡 Medium |
| Third-party cookies | Any SameSite=None cookies without Secure? |
— | 🟡 Medium |
| Deprecated APIs | Any browser-deprecated JS APIs in use? | — | 🟢 Low |
| Source maps | Are source maps deployed for debugging? | — | 🟢 Low |
When both netlify.toml and vercel.json exist, check BOTH. Each has a different syntax (TOML vs JSON).
| Check | What to Verify |
|---|---|
<title> tag |
Present, 50–60 chars, includes primary keyword |
| Meta description | Present, 150–160 chars, compelling |
| Canonical tag | <link rel="canonical"> points to correct URL |
| hreflang | Present if multilingual; correct language codes |
| robots.txt | Valid, not blocking key resources |
| Structured data | JSON-LD present; run Schema validator |
| Image alt attributes | Every <img> has meaningful alt |
| Link descriptiveness | No "click here" / "read more" link text |
| Crawlability | No noindex on important pages |
| HTTP status | 200 on main page and critical resources |
| SPA meta injection | If using react-helmet-async / Next.js Head: verify via "View Page Source", not DevTools Elements — meta tags may be JS-injected |
After completing all four batch scans, output a consolidated Risk vs Impact Matrix:
| Fix | Impact Score | Risk Level | Effort | Priority |
|----------------------------------|-------------|------------|----------|----------|
| Add defer/async to non-critical JS | High (LCP -0.8s est) | 🟢 Low | 1h | P1 |
| Convert images to WebP/AVIF | Medium (LCP -0.3s) | 🟢 Low | 2h | P1 |
| Add CSP header | Security | 🟡 Medium | 3h | P2 |
| Code-split main JS bundle | High (TBT -20ms) | 🟡 Medium | 1 day | P2 |
| Fix forced reflows | Medium (TBT -15ms) | 🔴 High | 2 days | P3 |
| Add HSTS preload | Security | 🟡 Medium | 30min | P2 |
Risk Level Definitions:
Always recommend: fix P1 (Low Risk, High Impact) items first, then P2, then P3.
Apply fixes in risk order. For each fix, provide:
Examples from common audits:
F1.1 — Move CSS @import to <link> tag
CSS @import url() is 2x render-blocking. Move to <link> in <head>:
/* Before: in index.css */
@import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');
<!-- After: in index.html <head> -->
<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'" />
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter&display=swap" /></noscript>
F1.2 — Defer render-blocking CSS (if not above-fold critical)
<!-- Before -->
<link rel="stylesheet" href="/assets/index.css">
<!-- After: load async, apply on load -->
<link rel="preload" href="/assets/index.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/assets/index.css"></noscript>
F1.3 — Fix broken preconnect (crossorigin mismatch)
<!-- Before (broken — no crossorigin on font CDN) -->
<link rel="preconnect" href="https://api.rss2json.com">
<!-- After -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Only preconnect origins used in critical path, max 4 -->
F1.4 — Convert images to WebP
# Using cwebp
cwebp -q 80 input.jpeg -o output.webp
# Using sharp (Node.js)
sharp('image.jpeg').webp({ quality: 80 }).toFile('image.webp')
# macOS fallback (sips built-in)
sips -s format webp input.jpeg --out output.webp
# Python Pillow fallback
python3 -c "
from PIL import Image
Image.open('input.jpg').save('output.webp', 'WebP', quality=80)
"
F1.5 — Add explicit image dimensions (CLS fix)
<!-- Before -->
<img src="hero.webp" alt="...">
<!-- After -->
<img src="hero.webp" alt="..." width="800" height="400">
F1.6 — Add security headers (netlify.toml)
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload"
Cross-Origin-Opener-Policy = "same-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=()"
Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://api.rss2json.com"
F1.7 — Add security headers (vercel.json)
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Strict-Transport-Security", "value": "max-age=31536000; includeSubDomains; preload" },
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
{ "key": "Content-Security-Policy", "value": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' https://api.rss2json.com" }
]
}
]
}
F1.8 — Self-host Google Fonts (eliminate external CSS request)
Download woff2 files and serve them locally to remove the Google Fonts CSS round-trip entirely:
# 1. Download woff2 files from Google Fonts CSS URL
# Open https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap
# in a browser, then download each woff2 URL listed in the @font-face blocks.
# 2. Place files in public/fonts/ or src/assets/fonts/
public/fonts/
inter-v12-latin-400.woff2
inter-v12-latin-700.woff2
# 3. Add @font-face CSS (load once, no external request)
/* src/styles/fonts.css */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-v12-latin-400.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/inter-v12-latin-700.woff2') format('woff2');
}
/* Remove the old Google Fonts <link> from index.html */
/* Before: */
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">
/* After: just use the font-family normally */
body { font-family: 'Inter', sans-serif; }
Result: Zero external CSS requests, faster FCP/LCP, no FOIT risk, and works offline.
F1.9 — Resize oversized icons
Icons (favicon, apple-touch-icon, OG image) should never be > 50KB. Check and resize:
python3 -c "
from PIL import Image
img = Image.open('favicon.png')
img.resize((192, 192)).save('favicon.png', 'PNG', optimize=True)
img.resize((32, 32)).save('favicon-32x32.png', 'PNG', optimize=True)
img.resize((16, 16)).save('favicon-16x16.png', 'PNG', optimize=True)
"
F2.1 — Remove LCP element lazy loading
The LCP element must NEVER be lazy-loaded:
<!-- Before: wrong — LCP image is lazy -->
<img src="hero.webp" loading="lazy" ...>
<!-- After: eager load the above-fold LCP element -->
<img src="hero.webp" loading="eager" fetchpriority="high" ...>
F2.2 — Preload LCP image
⚠️ Only works for files in public/ or with stable URLs. If using Vite/Webpack (content-hashed filenames), use <picture> + fetchPriority="high" instead:
<!-- For stable URLs (public/ directory): -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- For hashed filenames (Vite/Rollup): use component-level approach -->
<picture>
<source srcSet={webpImage} type="image/webp" />
<img src={jpgImage} fetchPriority="high" loading="eager" width="1920" height="1080" />
</picture>
F2.3 — Reduce unused JS (Vite/Rollup config)
// vite.config.js — enable manual chunking
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
rss: ['rss-parser'],
}
}
}
}
F2.4 — Eliminate forced reflows
// Before: reads layout property inside animation loop
element.addEventListener('scroll', () => {
const h = element.offsetHeight; // triggers reflow
doSomething(h);
});
// After: cache geometry reads outside event handlers
const h = element.offsetHeight; // read once
element.addEventListener('scroll', () => {
doSomething(h);
});
F2.5 — Optimise DOM size
If DOM > 1,500 elements:
F3.1 — External API in critical path (e.g. api.rss2json.com)
Current: HTML → JS bundle → external API (adds 1,574ms to critical path)
Solution: Move external API calls to build time or server-side:
// Option A: Fetch at build time (Astro/Next.js SSG)
export async function getStaticProps() {
const res = await fetch('https://api.rss2json.com/v1/api.json?rss_url=...');
const data = await res.json();
return { props: { posts: data.items }, revalidate: 3600 };
}
// Option B: Edge function / serverless proxy
// Cache RSS response at CDN edge, return stale-while-revalidate
F3.2 — Content Security Policy (full CSP)
Build the CSP iteratively:
Content-Security-Policy-Report-Only
Before deploying any fix batch, run these checks:
Build:
□ npm run build (or equivalent) — exits 0
□ npm run lint / typecheck — no new errors vs baseline
□ Inspect dist/ output:
- No single JS chunk > 500KB (gzipped)
- CSS < 100KB
- Code-splitting created separate vendor chunks
Asset verification:
□ For Vite/Rollup/Webpack: preload <link> in index.html won't match hashed filenames.
Use fetchPriority="high" + <picture> on the component instead.
□ Favicons and icons are < 50KB each (not multi-MB source images used as icons)
□ WebP/AVIF versions exist alongside originals
Deploy target:
□ If dual-deployed (Netlify + Vercel), verify headers on BOTH
□ If using SPA framework: verify meta tags via "View Page Source", not DevTools Elements
(react-helmet-async injects at runtime — check prerendered/SSR output)
After deploying each fix batch, verify:
Performance:
□ Re-run PageSpeed Insights on mobile AND desktop
□ LCP < 2.5s (Good)
□ FCP < 1.8s (Good)
□ TBT < 200ms (Good)
□ CLS < 0.1 (Good)
□ SI < 3.4s (Good)
Accessibility:
□ Run axe DevTools browser extension
□ Navigate page with keyboard only (Tab, Shift+Tab, Enter, Space)
□ Test with screen reader (NVDA/VoiceOver)
□ Check contrast with browser DevTools accessibility panel
Best Practices:
□ Verify security headers at https://securityheaders.com
□ Check HTTPS: no mixed content warnings in DevTools
□ Run Lighthouse Best Practices audit again
SEO:
□ Validate structured data at https://search.google.com/test/rich-results
□ Check robots.txt at /robots.txt
□ Verify canonical tag in page source (View Source, not DevTools)
□ Submit updated sitemap to Google Search Console
| Metric | Good | Needs Work | Poor |
|---|---|---|---|
| FCP | < 1.8s | 1.8–3.0s | > 3.0s |
| LCP | < 2.5s | 2.5–4.0s | > 4.0s |
| TBT | < 200ms | 200–600ms | > 600ms |
| CLS | < 0.1 | 0.1–0.25 | > 0.25 |
| SI | < 3.4s | 3.4–5.8s | > 5.8s |
User: "My site scores 65 on Performance. LCP is 4.2s."
Agent:
User: "Why is my LCP slow?"
Agent:
After each fix batch, log what changed and whether it caused build failures:
| Fix | File(s) Modified | Build Pass? | Errors | Revert Steps |
|---|---|---|---|---|
F1.1 — CSS @import → <link> |
index.html, src/styles/*.css |
□ Yes □ No | Restore original <link> tags |
|
| F1.2 — Defer render-blocking CSS | index.html |
□ Yes □ No | Remove media="print" + onload |
|
| F1.4 — WebP conversion | public/images/*.webp |
□ Yes □ No | Delete .webp files, restore originals | |
| F1.5 — Image dimensions | src/components/*.tsx |
□ Yes □ No | Remove width/height/loading attrs |
|
| F1.6 — Security headers (Netlify) | netlify.toml |
□ Yes □ No | Delete the [[headers]] block |
|
| F1.7 — Security headers (Vercel) | vercel.json |
□ Yes □ No | Remove the "headers" array entry |
|
| F1.8 — Self-host fonts | public/fonts/*.woff2, src/styles/fonts.css, index.html |
□ Yes □ No | Delete font files, remove @font-face, restore Google Fonts <link> |
|
| F1.9 — Resize icons | public/favicon*, public/apple-touch-icon*, public/og-image* |
□ Yes □ No | Restore original icon files | |
| F2.1 — LCP eager loading | src/components/*.tsx |
□ Yes □ No | Change loading="eager" back to loading="lazy" |
|
| F2.2 — Preload LCP image | index.html or src/components/*.tsx |
□ Yes □ No | Remove <link rel="preload"> or revert <picture> |
|
| F2.3 — Code-split JS | vite.config.ts |
□ Yes □ No | Remove manualChunks config |
|
| F2.4 — Fix forced reflows | src/**/*.ts |
□ Yes □ No | Revert geometry caching changes | |
| F2.5 — Optimise DOM | src/components/*.tsx |
□ Yes □ No | Restore removed hidden nodes | |
| F3.1 — External API to build time | src/**/*.ts, config files |
□ Yes □ No | Restore client-side fetch | |
| F3.2 — CSP headers | netlify.toml / vercel.json |
□ Yes □ No | Remove or relax CSP directives |
If Build Pass? is No, run npm run build to see the exact error, revert the failed fix immediately, and re-test before applying the next batch.
See references/ for deep-dives:
references/performance-deep-dive.md — LCP, CLS, TBT root cause treesreferences/security-headers.md — Complete CSP/HSTS/COOP referencereferences/image-optimization.md — WebP/AVIF conversion pipelines