Astro Performance Skill
Purpose
Achieve 90+ Lighthouse mobile scores and pass Core Web Vitals on every page. Direct impact on SEO rankings, crawl priority, and conversion rates.
Core Web Vitals Targets
| 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 |
Critical Path Rule
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
Core Rules
1. LCP Preloading (Biggest Impact)
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">
- The
preloadImageprop auto-detects MIME type from extension - Each page passes its OWN hero image — never hardcode a single image for all pages
- Only ONE
fetchpriority="high"per page (the preload + the<img>tag)
2. Font Strategy
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.
3. Images
Use the <Picture> component from astro-images skill with pattern-based srcset.
See images reference for details.
Key rules:
- Three formats always: AVIF → WebP → JPG (never PNG fallback for photos)
- 480w variant in every pattern
loading="eager"on hero and above-fold onlyloading="lazy"on everything below-fold- Never
loading="lazy"on hero. Neverloading="eager"on below-fold. - Explicit
width/heighton ALL images including SVGs width/heightmust match delivered dimensions, not original source
4. Third-Party Scripts
Defer 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.
5. CSS Strategy
- ONE main CSS file (Layout.css) — this is the only render-blocking CSS allowed
- Component CSS that's only used below-fold: use
<style is:inline>to move it into the HTML body (not a blocking<link>in<head>) - Never add extra
<link rel="stylesheet">in<head>unless absolutely needed above-fold - Below-fold component CSS total impact: monitor, should not add >2KB inline
6. CLS Prevention
- Every
<img>and<iframe>needswidth+heightattributes - SVG
<img>tags included — read dimensions from SVGviewBox - Use
aspect-ratioCSS as backup - Reserve space for dynamic content with
min-height - See CLS reference
7. Bundle Size
| 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) |
8. Caching
- Hashed assets (
/_astro/*):Cache-Control: public, max-age=31536000, immutable - HTML:
Cache-Control: public, max-age=0, must-revalidate - Fonts:
Cache-Control: public, max-age=31536000, immutable - See caching reference
Lighthouse Score Variability
Lighthouse 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:
- Run 3–5 times and take the median score
- Use
lighthouse --preset=perflocally for consistent results - Google uses real user data (CrUX) for ranking, not single Lighthouse runs
- Don't chase single-digit improvements after 90+
# 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
Subpage Performance Checklist
Don't only test the homepage. Different page types have different performance profiles.
Test at minimum:
- Homepage
/ - Longest service page (most content)
- An area/location page
- Reviews page (many DOM elements)
- Calculator page (JS-heavy)
Common subpage-specific issues:
- Large inline JSON-LD schema in
<head>→ move to</body>or minimize - Many review cards rendered at once → large DOM, slow layout
- Missing
preloadImageprop → hero image discovered late - Extra
<style is:inline>blocks from components → HTML size bloat
Integration with Other Skills
| 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 |
Boilerplate
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.
References
Core Web Vitals
- LCP Optimization — Preload via Layout prop, critical path, hero image pattern
- CLS Prevention — Dimensions, skeletons, font display, SVG width/height
- INP Optimization — Task chunking, debouncing, content-visibility
Assets & Resources
- Bundle Size — Analysis, tree shaking, dynamic imports
- Fonts — Swap vs optional, lazy CSS for non-critical, subsetting
- Images — Pattern-based srcset, format priority, SVG rules
Infrastructure
- Third-Party Scripts — GTM defer, Tag Gateway, FB Pixel, facade pattern
- Caching — Cloudflare headers, cache control
- Testing — Lighthouse CLI, batch runs, real user monitoring
Forbidden
- Extra
<link rel="stylesheet">in<body>(use<style is:inline>instead for below-fold component CSS) - Extra
<link rel="stylesheet">in<head>for below-fold components - Synchronous third-party scripts in
<head>(defer or usedeferGtmMs) - PNG fallback for photo images (use JPG)
- Unoptimized images / missing AVIF+WebP variants
font-display: swapon non-critical font variants (useoptional+ lazy CSS)- Non-critical font variants loaded in render-blocking CSS
font-display: block(blocks rendering up to 3s)- Preloading non-critical fonts (defeats lazy loading)
loading="lazy"on hero imagesloading="eager"on below-fold images- Missing
width/heighton any<img>or<iframe>(including SVGs) width/heightset to original source dimensions instead of delivered size- Layout shifts from dynamic content without reserved space
- Main thread blocking >50ms without chunking
- More than ONE
fetchpriority="high"per page - Hardcoded preload URL in Layout (use
preloadImageprop per page) - Modifying Cloudflare Tag Gateway scripts (
/ry2s/) in HTML
Definition of Done
- [ ] Lighthouse mobile ≥90 on homepage (median of 3 runs)
- [ ] Lighthouse mobile ≥85 on worst subpage (median of 3 runs)
- [ ] LCP ≤2.5s (homepage) / ≤3.5s (subpages)
- [ ] CLS ≤0.1 on all pages
- [ ] INP ≤200ms on calculator/interactive pages
- [ ] Critical path: max 3 hops (HTML → CSS → LCP image)
- [ ] No font in critical path chain (italic/display = lazy CSS)
- [ ] Total own JS <50KB gzipped
- [ ] Every page has
preloadImageprop with correct hero - [ ] Hero image preloaded,
loading="eager",fetchpriority="high" - [ ] All below-fold images:
loading="lazy" - [ ] All
<img>and<iframe>havewidth+height(including SVGs) - [ ] Fonts self-hosted with correct display strategy
- [ ] Third-party scripts deferred (GTM:
deferGtmMs, FB: setTimeout) - [ ] OG images generated from hero (5 variants, JPG)
- [ ] Tested on ≥3 different page types, not just homepage