Agent Skills: Astro Performance Skill

Core Web Vitals and performance optimization for Astro sites. LCP, CLS, INP optimization, bundle size, fonts, third-party scripts. Use for performance tuning.

UncategorizedID: Soborbo/claudeskills/astro-performance

Install this agent skill to your local

pnpm dlx add-skill https://github.com/Soborbo/claudeskills/tree/HEAD/astro-performance

Skill Files

Browse the full folder contents for astro-performance.

Download Skill

Loading file tree…

astro-performance/SKILL.md

Skill Metadata

Name
astro-performance
Description
Core Web Vitals and performance optimization for Astro sites. LCP preloading, font strategy, image patterns, critical path, third-party scripts, Cloudflare Tag Gateway. Use for performance tuning.

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 preloadImage prop 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 only
  • loading="lazy" on everything below-fold
  • Never loading="lazy" on hero. Never loading="eager" on below-fold.
  • Explicit width/height on ALL images including SVGs
  • width/height must 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> needs width + height attributes
  • SVG <img> tags included — read dimensions from SVG viewBox
  • Use aspect-ratio CSS 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=perf locally 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 preloadImage prop → 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

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 use deferGtmMs)
  • PNG fallback for photo images (use JPG)
  • Unoptimized images / missing AVIF+WebP variants
  • font-display: swap on non-critical font variants (use optional + 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 images
  • loading="eager" on below-fold images
  • Missing width/height on any <img> or <iframe> (including SVGs)
  • width/height set 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 preloadImage prop 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 preloadImage prop with correct hero
  • [ ] Hero image preloaded, loading="eager", fetchpriority="high"
  • [ ] All below-fold images: loading="lazy"
  • [ ] All <img> and <iframe> have width + 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