Optimizing an Astro Site for 100 PageSpeed
A practical guide to pushing an Astro site to a 100 PageSpeed score: CSS budgets, fonts, images, and the Speed Index fixes that actually matter.
A practical guide to pushing an Astro site to a 100 PageSpeed score: CSS budgets, fonts, images, and the Speed Index fixes that actually matter.
You built the site in Astro. It ships static HTML, barely touches JavaScript, and feels fast when you click around locally. Then you run PageSpeed Insights and get something like 88 on mobile with a Speed Index around 57. Which is not terrible, but also not the 100 you were hoping for.
That gap is normal. Astro gives you a strong baseline, but Lighthouse still measures real-world constraints: how much CSS blocks first paint, how heavy your fonts are, whether images match their display size, and whether animations delay when the page looks finished.
This guide walks through the optimizations that reliably move the needle on an Astro site, in the order that usually matters most. None of this requires turning your website into a plain text document, I promise that you’ll be able to keep the design.
Before changing anything, capture a Lighthouse report on a production build served over HTTPS.
pnpm build
pnpm preview
# In another terminal:
npx lighthouse https://localhost:4321/ \
--only-categories=performance \
--form-factor=mobile \
--output=html \
--output-path=./lighthouse-before.htmlWrite down four numbers:
| Metric | What it tells you |
|---|---|
| LCP | Largest visible element, usually a hero image or heading block |
| Speed Index | How quickly content appears to be painted (a score of ~57 means noticeable delay) |
| TBT | Main-thread blocking from JavaScript |
| CLS | Layout shift from fonts, images, or late-injected styles |
On a typical content site built with Astro, TBT is often already low. The fight for 100 is usually CSS delivery, fonts, images, and above-the-fold animations instead of shaving another kilobyte off a 4 KB script.
Astro’s default output, which is fully pre-rendered HTML at build time, is the single biggest architectural advantage you have. Every client-side router, every client:load island, and every global state provider adds JavaScript that Lighthouse will measure.
Rules of thumb:
client:visible or client:idle only when interaction genuinely needs hydration.<a href> navigation over client-side routing for content sites.If your homepage hydrates a React app shell just to avoid full page reloads, you’ve traded a few hundred milliseconds of perceived navigation smoothness for a permanent performance tax on first load. For a blog or marketing site, that trade rarely pays off.
The most common reason an Astro site stalls around 90-95 on mobile is shipping too much CSS on pages that don’t need it.
A pattern that hurts scores: importing @tailwindcss/typography and all .prose overrides in a global stylesheet loaded on every page via BaseHead.astro. Your homepage doesn’t render long-form article markup, but it still downloads and parses typography rules for h1-h6, blockquotes, code blocks, and tables.
Fix: split global CSS from content CSS.
src/styles/
global.css ← layout, colors, components used everywhere
prose.css ← @tailwindcss/typography + article overridesImport prose.css only in layouts that render MDX or markdown body content:
---
// BlogPost.astro
import "../styles/prose.css";
---On a mid-size site, this alone can cut the homepage stylesheet in half. Less CSS means faster parse time, fewer “unused CSS” warnings, and a lower Speed Index.
While you’re at it, audit global.css for dead tokens and custom utilities that nothing references. Tailwind v4 purges unused utility classes, but handwritten @utility blocks and orphaned CSS variables stay in the bundle forever.
Even a 40 KB stylesheet is render-blocking when served as <link rel="stylesheet">. The browser must download and parse it before painting styled content, which is exactly what Speed Index penalizes.
Astro has a built-in switch for this:
// astro.config.mjs
export default defineConfig({
build: {
inlineStylesheets: "always",
},
});With "always", Astro embeds each page’s CSS in <style> tags inside <head> instead of separate files. First paint gets styles immediately; no extra round trip.
Trade-off: HTML documents grow. A homepage might go from 30 KB to 90 KB of HTML+CSS combined. For most static sites on HTTP/2 or HTTP/3 with Brotli, that’s a good trade! One request beats two, and styled first paint matters more than raw transfer size on a landing page.
If "always" feels too aggressive, you can start with "auto" (inlines only small sheets) and move to "always" once you’ve trimmed CSS with step 2.
Fonts are a triple penalty: extra bytes to download, potential layout shift when they swap in, and render delay if the browser waits on them.
A practical two-font setup for most sites:
| Role | Font | Loading strategy |
|---|---|---|
| Headings | One display face (local .woff2 preferred) | preload + font-display: swap |
| Body | System stack (ui-sans-serif, system-ui) | No download |
| Code / terminal accent | One monospace face, single weight | Load only on pages that use it |
// astro.config.mjs
fonts: [
{
provider: fontProviders.local(),
name: "YourDisplay",
cssVariable: "--font-display",
options: {
variants: [
{
src: ["./src/assets/fonts/display-regular.woff2"],
weight: 400,
style: "normal",
display: "swap",
},
],
},
},
],/* global.css */
@theme {
--font-sans: ui-sans-serif, system-ui, sans-serif;
--font-display: var(--font-display), ui-sans-serif, system-ui, sans-serif;
}What to avoid:
A site that ships 20+ @font-face rules in the document head is paying for families and weights it never renders. Count them in your built HTML. And if the number surprises you, trimming would be enough.
Astro’s <Image> component (from astro:assets) handles format conversion, responsive srcset, and lazy loading. Use it everywhere you can instead of raw <img src="/photo.jpg">.
LCP image (hero cover, above-the-fold photo):
<Image
src={heroImage}
alt="Descriptive alt text for the article"
loading="eager"
fetchpriority="high"
widths={[640, 960, 1280]}
sizes="100vw"
/>Card thumbnails (below the fold or in a grid):
<Image
src={thumbnail}
alt={`Thumbnail for ${title}`}
widths={[330, 520]}
sizes="(min-width: 1024px) 33vw, (min-width: 768px) 50vw, 100vw"
class="h-full w-full object-cover"
/>Key details:
widths and sizes must reflect how the image actually displays. Serving a 1200 px wide asset in a 320 px card is one of the most common Lighthouse image warnings.fetchpriority="high" belongs on one LCP candidate per page, not on every image.object-cover avoids letterboxing tricks that waste pixels or distort aspect ratios.Store source images as high-quality PNG or JPEG; let the build pipeline emit WebP/AVIF. Don’t hand-optimize every size yourself.
Speed Index measures how quickly the page looks complete. Not when the JavaScript completes loading and execution, but when pixels stop changing in a meaningful way.
Hero entrance animations are a frequent culprit. A staggered fade-in where the marquee starts at animation-delay: 2.9s means Lighthouse still sees an incomplete visual state seconds after first paint. A Speed Index of ~57 often points here.
Compositor-friendly properties:
| Prefer | Avoid for continuous animation |
|---|---|
transform, opacity | width, height, top, left |
scale, translate | box-shadow, clip-path (for long sequences) |
Practical tweaks:
transform + opacity onlyprefers-reduced-motion: reduce@media (prefers-reduced-motion: reduce) {
.hero-section * {
animation: none !important;
}
}You don’t have to remove personality from the design. You do have to stop treating a 3-second animation timeline as free.
Astro sites often score well on Total Blocking Time without trying. Protect that advantage:
import() inside client scripts, not as top-level bundles on every page.IntersectionObserver in 15 lines beats importing a library.If TBT is already under 50 ms, stop optimizing JavaScript and go back to CSS and fonts.
Static output benefits from a final minification pass. The @playform/compress Astro integration runs after the build and shrinks HTML, CSS, and JS in dist/:
import compress from "@playform/compress";
export default defineConfig({
integrations: [
compress({
CSS: true,
HTML: true,
JavaScript: true,
Image: false, // Astro's image pipeline already optimizes
SVG: false, // keep favicons and icons untouched
}),
],
});Expect modest gains, around a few kilobytes per page, but it’s zero-effort once configured.
Performance bugs sometimes live outside your repo. Common ones:
/blog → /blog/ → final URL adds latency on every internal link. Set trailingSlash: "always" (or "never") and link consistently.Serve the complete dist/ directory from your host. If you deploy to Cloudflare Workers, Vercel, or Netlify, verify that /_astro/* and font paths resolve on the production URL, not just on preview.
Run Lighthouse on:
A homepage at 100 and a blog post at 94 is normal. Article pages carry more CSS and sometimes client JS. Optimize the homepage first; accept that content templates have a higher floor.
| Stage | Typical mobile performance |
|---|---|
| Stock Astro + Tailwind, no tuning | 85-92 |
| After CSS split + font diet | 93-97 |
| After inline CSS + image sizing + animation tuning | 97-100 |
Your Speed Index of ~57 before optimization might land in the 75-90 range after CSS and animation fixes alone, often a bigger visual win than chasing the last point on LCP.
Work through this in order. Stop when you hit your target.
inlineStylesheets: "always" once CSS is lean<Image> with widths/sizes on every content imagefetchpriority="high" on the single LCP image/_astro/* without 404 error codesA PageSpeed score of 100 is a lab measurement under throttled mobile CPU and network. Real users on fast connections will feel instant either way. All you need is smaller CSS budgets, fewer fonts, correctly sized images, and no accidental client bundles on pages that should be pure HTML.
Astro makes that discipline easier than most frameworks. You still have to apply it. Start with the Lighthouse report, fix the biggest line item, rebuild, and repeat. The jump from a Speed Index of ~57 to the high 80s or 90s usually takes an afternoon. The last few points are polish, and worth it only if the homepage is the page that matters most.
Share article