← All articles
AstroPerformanceWeb

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.

Axel Isouard12 min read

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.

Start with a baseline

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.html

Write down four numbers:

MetricWhat it tells you
LCPLargest visible element, usually a hero image or heading block
Speed IndexHow quickly content appears to be painted (a score of ~57 means noticeable delay)
TBTMain-thread blocking from JavaScript
CLSLayout 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.

1. Stay static (or as close as possible)

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:

  • Default to zero client directives on marketing pages.
  • Use client:visible or client:idle only when interaction genuinely needs hydration.
  • Prefer <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.

2. Treat CSS as a per-route budget

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 overrides

Import 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.

3. Eliminate render-blocking stylesheets

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.

4. Put your fonts on a diet

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:

RoleFontLoading strategy
HeadingsOne display face (local .woff2 preferred)preload + font-display: swap
BodySystem stack (ui-sans-serif, system-ui)No download
Code / terminal accentOne monospace face, single weightLoad 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:

  • Loading three families (sans + display + mono) on every page
  • Pulling in 400/500/600 × normal/italic from Google Fonts when you use one weight
  • Preloading fonts that only appear below the fold

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.

5. Images: right size, right format, right priority

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.

6. Tune animations for Speed Index

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:

PreferAvoid for continuous animation
transform, opacitywidth, height, top, left
scale, translatebox-shadow, clip-path (for long sequences)

Practical tweaks:

  • Shorten stagger delays. Aim for the full hero to settle within ~1.5 s
  • Run infinite loops (pulses, marquees) on transform + opacity only
  • Gate decorative motion behind prefers-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.

7. Keep JavaScript off the critical path

Astro sites often score well on Total Blocking Time without trying. Protect that advantage:

  • Load lightbox, analytics, and comment widgets with dynamic import() inside client scripts, not as top-level bundles on every page.
  • Keep navigation logic inline and tiny if you need scroll-based behavior. An IntersectionObserver in 15 lines beats importing a library.
  • Don’t add a framework island “just in case.”

If TBT is already under 50 ms, stop optimizing JavaScript and go back to CSS and fonts.

8. Compress at build time

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.

9. Don’t forget deployment

Performance bugs sometimes live outside your repo. Common ones:

  • Missing static assets: font files referenced in CSS but not uploaded to the CDN return 404 and log console errors (hurts Best Practices score).
  • Wrong cache headers: HTML cached too aggressively, or fonts cached too briefly.
  • Redirect chains: /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.

10. Measure again, page by page

Run Lighthouse on:

  1. Homepage: leanest CSS, highest bar for 100
  2. A long article: prose CSS, code blocks, possibly a lightbox script
  3. An index page: image grids, pagination

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.

StageTypical mobile performance
Stock Astro + Tailwind, no tuning85-92
After CSS split + font diet93-97
After inline CSS + image sizing + animation tuning97-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.

A sane checklist

Work through this in order. Stop when you hit your target.

  1. Split prose/typography CSS to content layouts only
  2. Remove dead CSS from global styles
  3. inlineStylesheets: "always" once CSS is lean
  4. Two fonts max: system stack for body, preload only what you render
  5. <Image> with widths/sizes on every content image
  6. fetchpriority="high" on the single LCP image
  7. Shorten hero animation delays; use compositor properties
  8. Build-time compression integration
  9. Verify deploy serves fonts and /_astro/* without 404 error codes

What 100 actually means

A 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