Performance

Performance

Production-tested performance patterns from the ActiveWizards Astro site. Targets: LCP <2.5s, CLS <0.1, INP <200ms.


1. Font Loading

Fonts are the most common source of invisible text (FOIT) and layout shift. Get this right first.

Variable Fonts + font-display: swap

/* Import from Fontsource CDN -- individual weights, not the full family */
@import url('https://cdn.jsdelivr.net/fontsource/fonts/geist-sans@latest/latin-400-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/geist-sans@latest/latin-500-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/geist-sans@latest/latin-600-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/geist-sans@latest/latin-700-normal.css');
@import url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono@latest/latin-400-normal.css');

Preload Critical Fonts

In your Astro layout :

<link rel="preload" href="/fonts/GeistSans-Regular.woff2"
      as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/GeistMono-Regular.woff2"
      as="font" type="font/woff2" crossorigin />

Self-Hosting vs CDN

ApproachProsCons

Fontsource CDNZero setup, always latestExtra DNS lookup, no cache control
Self-hostedFull cache control, no external dependencyMust update manually

For production, self-host fonts in public/fonts/ and reference them with @font-face:

@font-face {
  font-family: 'Geist Sans';
  src: url('/fonts/GeistSans-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

Font Stack Fallback

Always include a system font fallback that matches the custom font's metrics:

--font-sans: 'Geist Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', monospace;

2. CSS Optimization

Tailwind v4: Automatic Purge

Tailwind v4 automatically tree-shakes unused utility classes. The import is a single line:

@import "tailwindcss";

At build time, Astro + Tailwind produce only the CSS classes actually used in your templates. Typical output: 15-30KB gzipped for a full site.

Minimal Custom CSS

Custom CSS should be limited to:

  • Design system tokens (@theme block)
  • Animations (@keyframes)
  • Prose styles for markdown content
  • Component-specific overrides that cannot be expressed in Tailwind

@theme {
  --color-bg-primary: #0A0A0F;
  --color-accent: #00D4AA;
  --font-sans: 'Geist Sans', system-ui, sans-serif;
  /* ... */
}

Critical CSS

Astro inlines critical CSS automatically for static pages. For dynamic islands, ensure above-the-fold styles are in the main CSS bundle, not loaded via JavaScript.

Eliminate Box Shadows

The Cyber-Brutalist doctrine bans box-shadows globally. This saves rendering time on every element:

*, *::before, *::after {
  box-shadow: none !important;
  text-shadow: none !important;
}

3. JavaScript: Zero/Minimal JS Doctrine

Islands Architecture

Astro renders everything to static HTML by default. JavaScript only loads for interactive components (islands). This is the single biggest performance advantage over React/Next.js/SvelteKit.

<!-- Static: zero JS shipped -->
<Header />
<BlogPost content={post.body} />
<Footer />

<!-- Interactive island: JS loaded only for this component -->
<ContactForm client:visible />

Island Loading Strategies

DirectiveWhen JS LoadsUse Case

client:loadOn page loadCritical interactivity (nav menu)
client:visibleWhen element enters viewportBelow-fold forms, comments
client:idleAfter page is idleAnalytics, non-critical widgets
client:mediaWhen media query matchesMobile-only components
(none)NeverStatic content (99% of your site)

JavaScript Budget

Target: <50KB total JS for the entire site. The AW site ships ~15KB (navigation toggle + contact form validation).

Avoid:

  • Heavy frameworks on every page (React, Vue)
  • Analytics libraries that block rendering (load async)
  • Third-party widgets that inject their own CSS/JS
  • Smooth scroll libraries (use CSS scroll-behavior: smooth)


4. LCP (Largest Contentful Paint)

Target: <2.5s

The LCP element is usually the hero image or the largest heading. Optimize for it specifically.

Hero Image Preload

<head>
  <link rel="preload" as="image" href="/images/hero.webp"
        type="image/webp" fetchpriority="high" />
</head>

Hero Image Attributes

<img
  src="/images/hero.webp"
  alt="Hero description"
  width="1200"
  height="630"
  loading="eager"
  fetchpriority="high"
  decoding="async"
/>

Common LCP Killers

ProblemFix

Hero image not preloadedAdd in
Hero image lazy-loadedSet loading="eager"
Large unoptimized imageCompress to WebP, target <200KB
Font blocking renderUse font-display: swap
Render-blocking CSSAstro handles this; avoid external CSS imports
Server response timeUse CDN (Cloudflare Pages: <50ms TTFB globally)


5. CLS (Cumulative Layout Shift)

Target: <0.1

CLS happens when elements move after the page starts rendering. The main causes are images without dimensions and fonts that change size on load.

Explicit Dimensions on Everything

<!-- Images: always width + height -->
<img src="..." width="800" height="450" alt="..." />

<!-- Embeds: explicit aspect ratio -->
<div style="aspect-ratio: 16/9;">
  <iframe src="..." width="100%" height="100%" />
</div>

<!-- Ads/dynamic content: min-height placeholder -->
<div style="min-height: 250px;">
  <!-- Ad loads here -->
</div>

Font Swap Without Layout Shift

font-display: swap can cause CLS if the fallback font has different metrics. Match the fallback:
/* Geist Sans is close to system-ui metrics, so CLS is minimal */
body {
  font-family: 'Geist Sans', system-ui, -apple-system, sans-serif;
  font-size: 16px;
  line-height: 1.6;
}

Common CLS Sources

SourceFix

Images without dimensionsAdd width + height attributes
Dynamic content injectionReserve space with min-height
Font swapMatch fallback font metrics
Late-loading CSSInline critical CSS (Astro does this)
Sticky header that pushes contentUse fixed positioning, not sticky on load


6. INP (Interaction to Next Paint)

Target: <200ms

INP measures how fast the page responds to user interactions (clicks, taps, key presses).

Minimal JS = Fast INP

With Astro's zero-JS-by-default approach, INP is inherently good. Problems arise when you add heavy client-side JavaScript.

Rules

  • No long tasks (>50ms): Break up heavy JavaScript into smaller chunks
  • No layout thrashing: Batch DOM reads and writes
  • Debounce input handlers: Search, filter, and scroll handlers should debounce at 100-150ms
  • Use requestAnimationFrame for visual updates, not setTimeout
  • // Bad: layout thrashing
    element.style.width = '100px';
    const height = element.offsetHeight; // forces layout
    element.style.height = height + 'px'; // forces another layout
    
    // Good: batch reads then writes
    const height = element.offsetHeight;
    requestAnimationFrame(() => {
      element.style.width = '100px';
      element.style.height = height + 'px';
    });

    7. Lighthouse Audit Workflow

    Local Audit

    # Full audit with Unlighthouse (crawls all pages)
    npx unlighthouse --site https://staging.aw-site.pages.dev --reporter json
    
    # Single page with Lighthouse CLI
    npx lighthouse https://staging.aw-site.pages.dev/blog/sample-post --output=json --output-path=./lighthouse-report.json

    Thresholds

    CategoryMinimumTarget

    Performance9095+
    Accessibility95100
    Best Practices9095+
    SEO95100

    CI Integration

    Add Lighthouse to your deploy pipeline:

    # .github/workflows/lighthouse.yml
    - name: Lighthouse audit
      uses: treosh/lighthouse-ci-action@v12
      with:
        urls: |
          https://staging.example.com/
          https://staging.example.com/blog/sample-post
        budgetPath: ./lighthouse-budget.json

    Budget file:

    [{
      "path": "/*",
      "resourceSizes": [
        { "resourceType": "script", "budget": 50 },
        { "resourceType": "stylesheet", "budget": 30 },
        { "resourceType": "image", "budget": 300 },
        { "resourceType": "total", "budget": 500 }
      ],
      "timings": [
        { "metric": "largest-contentful-paint", "budget": 2500 },
        { "metric": "cumulative-layout-shift", "budget": 0.1 },
        { "metric": "interactive", "budget": 3500 }
      ]
    }]

    8. Cloudflare Caching

    Browser Cache Headers

    Cloudflare Pages sets Cache-Control headers automatically:

    • HTML: no-cache (always fresh)
    • Assets (JS, CSS, images): max-age=31536000, immutable (1 year, content-hashed filenames)

    Edge Caching

    Cloudflare caches static assets at 300+ edge locations. No configuration needed for Pages.

    For custom headers, add a _headers file to public/:

    /images/*
    

    Cache-Control: public, max-age=31536000, immutable

    /fonts/*

    Cache-Control: public, max-age=31536000, immutable

    Access-Control-Allow-Origin: *

    /*.html

    Cache-Control: public, max-age=0, must-revalidate

    Purge Strategy

    After deploy, Cloudflare Pages automatically invalidates HTML. Images and assets with content-hashed filenames never need purging.


    9. Bundle Analysis

    Astro Build Output

    astro build
    
    # Output shows bundle sizes:
    # dist/blog/post-slug/index.html    12.4 kB
    # dist/_astro/client.abc123.js       8.2 kB
    # dist/_astro/global.def456.css     22.1 kB

    What to Watch

    MetricHealthyWarningAction

    Total JS<50KB50-100KBAudit island usage
    Total CSS<30KB30-60KBCheck for unused Tailwind plugins
    Largest HTML page<100KB100-200KBCheck for inline SVGs or oversized content
    Number of requests (first load)<2020-40Consolidate or lazy-load


    10. Monitoring

    PageSpeed Insights API

    # Command-line check
    curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://yoursite.com&strategy=mobile&key=$PSI_KEY"

    Web Vitals JS Library

    Add real-user monitoring:

    <script type="module">
      import { onCLS, onINP, onLCP } from 'https://unpkg.com/web-vitals@4?module';
    
      function sendToAnalytics({ name, delta, id }) {
        // Send to your analytics endpoint
        gtag('event', name, {
          value: Math.round(name === 'CLS' ? delta * 1000 : delta),
          event_label: id,
          non_interaction: true,
        });
      }
    
      onCLS(sendToAnalytics);
      onINP(sendToAnalytics);
      onLCP(sendToAnalytics);
    </script>

    Cloudflare Web Analytics

    Free, privacy-focused, no JS needed (runs at the edge). Enable in CF Dashboard under the Pages project. Provides Core Web Vitals data from real users.

    Monitoring Cadence

    CheckFrequencyTool

    Lighthouse audit (staging)Every deployCI/CD pipeline
    PageSpeed Insights (production)WeeklyPSI API or manual
    Real User Metrics (CWV)ContinuousCF Web Analytics or web-vitals
    Bundle size regressionEvery deployastro build output


    Performance Checklist

    Pre-production gate. All items must pass before production deploy.

    • [ ] Lighthouse Performance >= 90 on mobile
    • [ ] LCP < 2.5s (hero image preloaded, eager loaded)
    • [ ] CLS < 0.1 (all images have width/height, fonts use swap)
    • [ ] INP < 200ms (minimal JS, no long tasks)
    • [ ] Total JS < 50KB
    • [ ] Total CSS < 30KB gzipped
    • [ ] Hero image < 200KB
    • [ ] All fonts use font-display: swap
    • [ ] No render-blocking external CSS
    • [ ] _headers file sets cache headers for images/fonts
    • [ ] No third-party scripts blocking first render
    • [ ] Sitemap and robots.txt accessible