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
| Approach | Pros | Cons |
| Fontsource CDN | Zero setup, always latest | Extra DNS lookup, no cache control |
| Self-hosted | Full cache control, no external dependency | Must 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 (
@themeblock) - 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
| Directive | When JS Loads | Use Case |
client:load | On page load | Critical interactivity (nav menu) |
client:visible | When element enters viewport | Below-fold forms, comments |
client:idle | After page is idle | Analytics, non-critical widgets |
client:media | When media query matches | Mobile-only components |
| (none) | Never | Static 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.5sThe 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
| Problem | Fix |
| Hero image not preloaded | Add in |
| Hero image lazy-loaded | Set loading="eager" |
| Large unoptimized image | Compress to WebP, target <200KB |
| Font blocking render | Use font-display: swap |
| Render-blocking CSS | Astro handles this; avoid external CSS imports |
| Server response time | Use CDN (Cloudflare Pages: <50ms TTFB globally) |
5. CLS (Cumulative Layout Shift)
Target: <0.1CLS 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
| Source | Fix |
| Images without dimensions | Add width + height attributes |
| Dynamic content injection | Reserve space with min-height |
| Font swap | Match fallback font metrics |
| Late-loading CSS | Inline critical CSS (Astro does this) |
| Sticky header that pushes content | Use fixed positioning, not sticky on load |
6. INP (Interaction to Next Paint)
Target: <200msINP 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
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
| Category | Minimum | Target |
| Performance | 90 | 95+ |
| Accessibility | 95 | 100 |
| Best Practices | 90 | 95+ |
| SEO | 95 | 100 |
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
| Metric | Healthy | Warning | Action |
| Total JS | <50KB | 50-100KB | Audit island usage |
| Total CSS | <30KB | 30-60KB | Check for unused Tailwind plugins |
| Largest HTML page | <100KB | 100-200KB | Check for inline SVGs or oversized content |
| Number of requests (first load) | <20 | 20-40 | Consolidate 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
| Check | Frequency | Tool |
| Lighthouse audit (staging) | Every deploy | CI/CD pipeline |
| PageSpeed Insights (production) | Weekly | PSI API or manual |
| Real User Metrics (CWV) | Continuous | CF Web Analytics or web-vitals |
| Bundle size regression | Every deploy | astro 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
- [ ]
_headersfile sets cache headers for images/fonts - [ ] No third-party scripts blocking first render
- [ ] Sitemap and robots.txt accessible