Astro Performance Optimization: A Complete Guide to Core Web Vitals
Astro is already one of the fastest frameworks for building content-focused websites. But understanding the techniques behind that performance helps you maintain it as your site grows and avoid common pitfalls that can slow things down.
In this guide, we’ll explore how Astro achieves its performance characteristics and how you can ensure your site hits perfect Core Web Vitals scores.
Understanding Core Web Vitals
Core Web Vitals are Google’s metrics for measuring user experience. They directly impact search rankings and, more importantly, user satisfaction.
The Three Pillars
| Metric | Measures | Target | Astro Strength |
|---|---|---|---|
| LCP (Largest Contentful Paint) | Loading | < 2.5s | Minimal JS, static HTML |
| INP (Interaction to Next Paint) | Interactivity | < 200ms | Islands architecture |
| CLS (Cumulative Layout Shift) | Visual stability | < 0.1 | Pre-calculated dimensions |
How Astro Achieves Fast LCP
Astro’s fundamental architecture is built for fast LCP:
---
// Server-side execution - no client JS needed
import { getCollection } from 'astro:content';
const posts = await getCollection('posts');
---
<!-- Static HTML - no hydration delay -->
<article>
<h1>{posts[0].data.title}</h1>
<p>{posts[0].data.description}</p>
</article>
This page ships zero JavaScript by default. The browser receives ready-to-render HTML immediately.
Image Optimization
Images are often the largest elements on a page. Astro’s <Image> component handles optimization automatically.
Using the Image Component
---
import { Image } from 'astro:assets';
import heroImage from '../images/hero.jpg';
---
<!-- Astro optimizes this automatically -->
<Image
src={heroImage}
alt="Hero image"
width={1200}
height={630}
loading="eager"
fetchpriority="high"
/>
What Astro Does Automatically
- Format conversion: Converts to WebP/AVIF for modern browsers
- Responsive srcset: Generates multiple sizes
- Dimension setting: Prevents CLS with explicit width/height
- Lazy loading:
loading="lazy"by default (except above-fold)
Hero Image Best Practices
For the LCP image (usually the hero):
<Image
src={heroImage}
alt="Descriptive alt text"
width={1200}
height={630}
loading="eager" {/* Don't lazy-load LCP */}
fetchpriority="high" {/* Prioritize download */}
decoding="async" {/* Don't block rendering */}
/>
Remote Images
For images from external sources:
// astro.config.mjs
export default defineConfig({
image: {
domains: ['images.unsplash.com', 'cdn.example.com'],
remotePatterns: [
{ protocol: 'https', hostname: '**.cloudinary.com' }
],
},
});
JavaScript Strategy: Islands Architecture
Astro’s island architecture is key to maintaining interactivity without sacrificing performance.
The Island Mental Model
┌──────────────────────────────────────────────────┐
│ Static HTML │
│ ┌──────────┐ ┌─────────────┐ │
│ │ Island 1 │ │ Island 2 │ │
│ │(Counter) │ │(Newsletter) │ │
│ │ client: │ │ client: │ │
│ │ visible │ │ idle │ │
│ └──────────┘ └─────────────┘ │
│ │
└──────────────────────────────────────────────────┘
Client Directives
---
import Counter from './Counter.jsx';
import Newsletter from './Newsletter.svelte';
import HeavyChart from './HeavyChart.tsx';
---
<!-- Hydrate when visible in viewport -->
<Counter client:visible />
<!-- Hydrate during browser idle time -->
<Newsletter client:idle />
<!-- Hydrate only on this media query -->
<HeavyChart client:media="(min-width: 768px)" />
<!-- Hydrate immediately (use sparingly) -->
<CriticalComponent client:load />
Choosing the Right Directive
| Directive | When to Use | Bundle Impact |
|---|---|---|
client:load | Above-fold critical interactivity | Immediate |
client:idle | Non-critical, background features | Deferred |
client:visible | Below-fold interactive elements | On-demand |
client:media | Responsive-only features | Conditional |
client:only | Skip SSR entirely | Framework-dependent |
Font Loading Optimization
Fonts can significantly impact LCP if not handled properly.
Using Astro’s Font API
---
import { Font } from 'astro:assets';
---
<Font
cssVariable="--font-heading"
preload={[{
subset: 'latin',
weight: 700,
style: 'normal'
}]}
/>
Font Loading Strategies
/* System font fallback */
:root {
--font-body: Inter, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
}
/* Font display swap - shows fallback until loaded */
@font-face {
font-family: 'Inter';
font-display: swap;
src: url('/fonts/inter.woff2') format('woff2');
}
Critical Font Preloading
<head>
<!-- Preload critical fonts -->
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
Build-Time Optimization
Content Collection Strategies
// Efficient: Filter at query time
const publishedPosts = await getCollection('posts',
({ data }) => !data.draft && data.pubDatetime <= new Date()
);
// Sort once, reuse
const sortedPosts = publishedPosts.sort(
(a, b) => b.data.pubDatetime.valueOf() - a.data.pubDatetime.valueOf()
);
Pagination for Large Collections
---
import type { GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const posts = await getCollection('posts');
return paginate(posts, { pageSize: 10 });
};
type Props = InferGetStaticPropsType<typeof getStaticPaths>;
const { page } = Astro.props;
---
{page.data.map(post => <PostCard post={post} />)}
<Pagination
prevUrl={page.url.prev}
nextUrl={page.url.next}
currentPage={page.currentPage}
totalPages={page.lastPage}
/>
Measuring Performance
Local Development
# Build and preview
npm run build && npm run preview
# Run Lighthouse
lighthouse http://localhost:4321 --view
# Use Pagespeed Insights API
curl "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=YOUR_URL"
Production Monitoring
Set up Real User Monitoring (RUM):
// Report Core Web Vitals
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
});
// Use sendBeacon for reliability
navigator.sendBeacon('/analytics', body);
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
Common Performance Pitfalls
1. Unnecessary Client Hydration
<!-- BAD: Static content doesn't need hydration -->
<StaticCard client:load />
<!-- GOOD: Render statically -->
<StaticCard />
2. Unoptimized Images
<!-- BAD: Raw img tag -->
<img src="/huge-image.jpg" />
<!-- GOOD: Astro Image component -->
<Image src={import('./huge-image.jpg')} alt="..." />
3. Blocking Third-Party Scripts
<!-- BAD: Blocking script -->
<script src="https://analytics.example.com/script.js"></script>
<!-- GOOD: Defer non-critical scripts -->
<script
src="https://analytics.example.com/script.js"
defer
></script>
4. Layout Shifts from Dynamic Content
<!-- BAD: No dimensions -->
<div class="ad-container"></div>
<!-- GOOD: Reserved space -->
<div class="ad-container" style="min-height: 250px;"></div>
Performance Checklist
Before deploying, verify:
- LCP element loads in < 2.5s
- Hero image uses
loading="eager"andfetchpriority="high" - All images have explicit dimensions
- Fonts use
font-display: swap - Critical CSS is inlined
- Third-party scripts are deferred
- JavaScript islands use appropriate client directives
- Large collections are paginated
- Static content isn’t unnecessarily hydrated
Conclusion
Astro’s architecture gives you a significant performance advantage out of the box. By understanding these optimization techniques, you can maintain that advantage as your site grows:
- Embrace static HTML - Let Astro’s zero-JS default work for you
- Optimize images - Use the Image component for automatic optimization
- Be strategic with islands - Only hydrate what needs interactivity
- Measure continuously - Monitor Core Web Vitals in production
Performance isn’t a one-time task—it’s an ongoing practice. The techniques in this guide will help you build and maintain blazing-fast Astro sites.
Related Resources: