Most performance advice is either obvious or irrelevant.
"Compress your images." Sure. "Use a CDN." Already doing it. "Lazy load below the fold." Done in 2019.
This is not that checklist. This is what I actually check — in order, on production sites — when I get handed a Lighthouse score in the 40s and need to move it to the 90s. Some of it came from auditing other people's sites. Most of it came from a hard migration on PW Store, where LCP went from 4 seconds to 1.2 seconds and the Lighthouse score went from 60 to 95+.
The Three Numbers That Matter
Google's Page Experience ranking signal is built on three metrics. Get them right and everything else follows.
| Metric | What It Measures | Good | Needs Work | Poor | |--------|-----------------|------|------------|------| | LCP | Largest Contentful Paint — when the main visible element loads | < 2.5s | 2.5–4s | > 4s | | INP | Interaction to Next Paint — how fast the page responds to input | < 200ms | 200–500ms | > 500ms | | CLS | Cumulative Layout Shift — unexpected layout movement | < 0.1 | 0.1–0.25 | > 0.25 |
INP replaced FID (First Input Delay) in March 2024. If your audit tools still show FID, update them — you're measuring the wrong thing.
1. LCP — Fix This First
LCP is almost always the highest-leverage metric. A bad LCP tanks both UX and ranking. A good INP and CLS won't save a 4-second LCP.
Find your LCP element first
Open Chrome DevTools → Performance tab → record a page load. In the timeline, look for the LCP marker. It tells you exactly which element triggered it.
Common LCP elements:
- Hero image (
<img>or CSSbackground-image) - Large heading text block
- Video poster frame
If it's a CSS background image: you're already losing. CSS backgrounds are not preload-eligible. Move critical hero images to <img> elements.
The preload that actually works
<!-- For an img element that is the LCP -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
fetchpriority="high" tells the browser to request this before it finishes parsing the rest of the <head>. Without it, the preload hint still exists but competes with everything else.
Do not preload more than 1–2 resources. Preloading 8 things is the same as preloading nothing — you've just told the browser everything is critical.
Image format and sizing
<img
src="/hero.webp"
width="1200"
height="630"
alt="Hero"
fetchpriority="high"
decoding="async"
/>
Always set width and height on <img>. Without them, the browser doesn't know the intrinsic size during layout — CLS appears as the image loads. This is the single most common CLS source I find on audits.
WebP cuts file size 25–35% over JPEG at equivalent quality. AVIF cuts another 20% over WebP but browser support is now sufficient (95%+) to use it with a WebP fallback:
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" width="1200" height="630" alt="Hero" fetchpriority="high" />
</picture>
Server response time
If TTFB (Time to First Byte) is above 600ms, nothing else you do will fix LCP. The browser can't paint what it hasn't received.
Check TTFB in the Network tab. If it's high: look at CDN cache hit rate, server-side rendering time, and database query time. A static page served from a CDN edge node should have TTFB under 100ms.
This is why Astro + S3 + CloudFront is so effective for content sites. Static HTML served from a CDN edge, no server computation on the critical path.
Render-blocking resources
<!-- Bad: blocks rendering -->
<link rel="stylesheet" href="/styles.css" />
<script src="/analytics.js"></script>
<!-- Better: defer non-critical JS -->
<script src="/analytics.js" defer></script>
<!-- Or async for truly independent scripts -->
<script src="/chat-widget.js" async></script>
Every render-blocking resource adds to LCP. defer is correct for most scripts. async is for scripts that don't depend on DOM order. Never use either on scripts that need to run before your page renders.
2. CLS — Mostly Caused by Three Things
CLS causes are highly repeatable. In every audit I've done, the root cause is one of three things.
1. Images without dimensions
Already covered above. Set width and height on every <img>. Done.
2. Fonts causing layout shift
Web fonts cause FOUT (Flash of Unstyled Text) or FOIT (Flash of Invisible Text) and layout shift as they swap in.
@font-face {
font-family: 'MyFont';
src: url('/fonts/myfont.woff2') format('woff2');
font-display: optional; /* or swap */
}
font-display: optional tells the browser: if the font isn't ready within a very short window, just use the fallback and don't swap later. No CLS. The tradeoff is that some users see the fallback font permanently.
font-display: swap shows fallback immediately then swaps when the font loads. It causes CLS but at least content is readable immediately.
font-display: block holds a blank space, then swaps. Causes FOIT. Avoid.
For critical fonts, self-host them and preload:
<link rel="preload" as="font" href="/fonts/myfont.woff2" type="font/woff2" crossorigin />
Self-hosting removes the DNS lookup for Google Fonts / Adobe Fonts on the critical path.
3. Dynamic content injecting above existing content
Banners, cookie notices, ads, "you might also like" sections — anything injected above existing content after load shifts everything below it.
Fix: reserve space in the layout for dynamic content before it loads. Even a min-height on the container prevents shift:
.cookie-banner-container {
min-height: 60px; /* reserve space before JS renders the banner */
}
Or better: put the banner at the bottom where it shifts nothing.
3. INP — The Hardest One to Fix
INP is interaction responsiveness. If a button click takes 300ms to visually respond, your INP is poor even if your LCP is perfect. It replaced FID precisely because FID only measured the first interaction — INP measures all of them.
The standard advice is "reduce long tasks." That's correct but not actionable. Here's what to actually do.
Measure it first
INP is a field metric, not a lab metric. You can't fully reproduce it in Lighthouse because it depends on real user interaction patterns. Use:
- Chrome UX Report (CrUX) — real user data aggregated by Google
- Web Vitals extension — shows real-time field data in your browser
web-vitalsJS library — log it from your own users
import { onINP } from 'web-vitals';
onINP(({ value, attribution }) => {
console.log('INP:', value, attribution.interactionTarget);
});
interactionTarget tells you which element is slow. That's where to start.
Long tasks on the main thread
Every task over 50ms blocks the main thread and contributes to poor INP. Find them in the Performance tab under the "Long Tasks" overlay.
Common sources:
- Heavy event handlers (parsing, sorting, DOM manipulation)
- Third-party scripts running on interaction
- Synchronous
localStoragereads inside handlers JSON.parseon large datasets
Fix: break work up with scheduler.yield() (Chrome 115+) or setTimeout(fn, 0) for critical-path tasks:
// Before: blocks main thread for 200ms
button.addEventListener('click', () => {
const result = heavyComputation(largeDataset);
updateUI(result);
});
// After: yield between heavy work and paint
button.addEventListener('click', async () => {
const result = heavyComputation(largeDataset);
await scheduler.yield(); // let the browser paint
updateUI(result);
});
Third-party scripts
Analytics, chat widgets, A/B testing tools — these are the most common source of INP regressions I find on audits. They run on your main thread, on your users' interactions, and you have no control over what they do.
Options:
- Load them with
deferorasync - Load them in a Web Worker if they support it (most don't)
- Evaluate whether you actually need them
Every third-party script you add is a liability on your INP budget.
4. The Tools
Lighthouse — lab measurement, good for catching issues. Run it in incognito to exclude extensions. The mobile simulation is pessimistic (throttled CPU, slow network) — that's intentional. If you can't pass mobile, you'll fail in the field.
PageSpeed Insights — combines lab (Lighthouse) + field (CrUX) data. The field data is what Google uses for ranking. If PSI field data is green, you're fine regardless of Lighthouse lab score.
WebPageTest — more detailed than Lighthouse. Waterfall view shows exactly what's blocking what and when. Use it when Lighthouse doesn't give enough signal.
Core Web Vitals report in Search Console — tells you which URLs have poor field data, segmented by device. Check this before running any audit. It shows you where to look.
5. The PW Store Numbers
The 4s → 1.2s LCP improvement on PW Store came from three things:
-
Server-side rendering replaced client-side data fetching for product data. The LCP element (product image) was previously waiting for an API response before it could start loading. Moving the data fetch to the server meant the image URL was in the HTML.
-
Image preload with
fetchpriority="high"on the hero product image. This alone dropped LCP by 600ms. -
Self-hosted fonts with
font-display: optionaleliminated the Google Fonts DNS lookup on the critical path and removed the font swap CLS.
None of these required a framework migration or architectural rewrite. They were configuration and markup changes.
The Checklist
Quick version for reference:
LCP
- [ ] Identify LCP element in Performance tab
- [ ] Move LCP image from CSS background to
<img> - [ ] Add
fetchpriority="high"to LCP image - [ ] Preload LCP image in
<head> - [ ] Convert images to WebP / AVIF
- [ ] Set
widthandheighton all images - [ ] Check TTFB — should be < 600ms
- [ ] Defer or async all non-critical scripts
CLS
- [ ]
width+heighton every<img> - [ ] Self-host fonts, add
font-display: optionalorswap - [ ] Preload critical fonts
- [ ] Reserve space for dynamic content (banners, ads)
INP
- [ ] Instrument with
web-vitalslibrary - [ ] Find slow interactions via
interactionTarget - [ ] Audit long tasks in Performance tab
- [ ] Yield between heavy computation and paint
- [ ] Evaluate and trim third-party scripts
If your scores are still in the 40s after working through this list, the problem is usually TTFB (server or CDN) or a third-party script you haven't identified yet. Neither is a quick fix — but they're findable.
If you want a professional audit that tells you exactly what to fix and in what order, see Frontend Performance Optimization.