Core Web Vitals and performance optimization for Astro sites. LCP, CLS, INP optimization, bundle size, fonts, third-party scripts. Use for performance tuning.
Achieve 90+ Lighthouse mobile scores and pass Core Web Vitals on every page. Direct impact on SEO rankings, crawl priority, and conversion rates.
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤2.5s | 2.5–4s | >4s |
| INP (Interaction to Next Paint) | ≤200ms | 200–500ms | >500ms |
| CLS (Cumulative Layout Shift) | ≤0.1 | 0.1–0.25 | >0.25 |
The critical rendering path must be max 3 hops: HTML → CSS → LCP image.
Anything that adds a 4th hop (font in CSS chain, extra CSS file, unpreloaded image) adds 150–500ms to LCP on mobile. This is the #1 cause of poor mobile scores.
GOOD: HTML (150ms) → Layout.css (150ms) → Hero image (preloaded, parallel)
Total: ~300ms to FCP, ~500ms to LCP
BAD: HTML (150ms) → Layout.css (150ms) → italic font (350ms) → Hero image (discovered late)
Total: ~650ms to FCP, ~2000ms+ to LCP
Every page MUST pass its hero image to BaseLayout for preloading:
<BaseLayout
preloadImage="/img/hero-480w.avif"
title="Page Title"
>
BaseLayout renders: <link rel="preload" as="image" href="..." type="image/avif" fetchpriority="high">
preloadImage prop auto-detects MIME type from extensionfetchpriority="high" per page (the preload + the <img> tag)Primary fonts (body, headings): font-display: swap + preload in <head>
Non-critical variants (italic, display, decorative): font-display: optional + lazy CSS
<!-- Primary: preload + swap (in <head>) — font from siteConfig.fonts -->
<link rel="preload" as="font" type="font/woff2" href="/fonts/body-font.woff2" crossorigin>
<!-- Non-critical: lazy load (NOT in <head> as blocking) -->
<link rel="stylesheet" href="/fonts/body-font-italic.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/fonts/body-font-italic.css"></noscript>
Why: A font in the main CSS adds it to the critical path chain (HTML → CSS → font = +300–500ms). Moving non-critical fonts to lazy CSS removes them from the chain entirely.
Never preload non-critical fonts — that defeats the lazy loading.
Use the <Picture> component from astro-images skill with pattern-based srcset.
See images reference for details.
Key rules:
loading="eager" on hero and above-fold onlyloading="lazy" on everything below-foldloading="lazy" on hero. Never loading="eager" on below-fold.width/height on ALL images including SVGswidth/height must match delivered dimensions, not original sourceDefer everything that isn't essential for first render.
<!-- GTM ID comes from siteConfig.tracking.gtmId — only deferGtmMs is a prop -->
<BaseLayout title="Page Title" deferGtmMs={2000}>
<!-- FB Pixel: always setTimeout if loaded directly -->
<!-- Tag Gateway: managed in Cloudflare Dashboard, NOT in code -->
See third-party scripts reference for Cloudflare Tag Gateway details.
<style is:inline> to move it into the HTML body (not a blocking <link> in <head>)<link rel="stylesheet"> in <head> unless absolutely needed above-fold<img> and <iframe> needs width + height attributes<img> tags included — read dimensions from SVG viewBoxaspect-ratio CSS as backupmin-height| Asset Type | Budget |
|---|---|
| Total JS (own code) | <50KB gzipped |
| Total JS (with 3rd party) | <100KB gzipped |
| Total CSS | <50KB gzipped |
| Hero image | <200KB |
| Any single image | <100KB |
| OG images | <150KB each (JPG q80) |
/_astro/*): Cache-Control: public, max-age=31536000, immutableCache-Control: public, max-age=0, must-revalidateCache-Control: public, max-age=31536000, immutableLighthouse mobile scores fluctuate ±10–15 points between runs. This is normal.
The slow 4G emulation is non-deterministic — the same page can score 65 on one run and 92 on the next. Same CSS file might take 150ms or 330ms to "load" in the emulation.
Best practice:
lighthouse --preset=perf locally for consistent results# Local batch testing
for i in 1 2 3 4 5; do
lighthouse https://example.com --only-categories=performance \
--preset=perf --form-factor=mobile --output=json \
--output-path="./lh-run-$i.json" --chrome-flags="--headless"
score=$(cat "lh-run-$i.json" | node -e "process.stdin.on('data',d=>console.log(Math.round(JSON.parse(d).categories.performance.score*100)))")
echo "Run $i: $score"
done
Don't only test the homepage. Different page types have different performance profiles.
Test at minimum:
/Common subpage-specific issues:
<head> → move to </body> or minimizepreloadImage prop → hero image discovered late<style is:inline> blocks from components → HTML size bloat| Skill | How it connects |
|---|---|
| astro-images | Use <Picture> component with patterns. LCP image = lcp prop. |
| design-tokens | Color contrast (WCAG AA 4.5:1) — poor contrast = a11y failure, not perf, but Lighthouse reports both |
| schema-entity-graph | Sitemap <lastmod> must sync with dateModified in schema — not a perf issue but often fixed in same pass |
| deployment | Pre-deploy checks, Cloudflare Workers config, output: 'static' for build-time image processing |
On first use in a project, copy the BaseLayout:
cp assets/boilerplate/layouts/BaseLayout.astro → src/layouts/BaseLayout.astro
Skip if the project already has it. The BaseLayout handles LCP preloading, GTM deferral, Schema.org rendering, and meta tags.
<link rel="stylesheet"> in <body> (use <style is:inline> instead for below-fold component CSS)<link rel="stylesheet"> in <head> for below-fold components<head> (defer or use deferGtmMs)font-display: swap on non-critical font variants (use optional + lazy CSS)font-display: block (blocks rendering up to 3s)loading="lazy" on hero imagesloading="eager" on below-fold imageswidth/height on any <img> or <iframe> (including SVGs)width/height set to original source dimensions instead of delivered sizefetchpriority="high" per pagepreloadImage prop per page)/ry2s/) in HTMLpreloadImage prop with correct heroloading="eager", fetchpriority="high"loading="lazy"<img> and <iframe> have width + height (including SVGs)deferGtmMs, FB: setTimeout)