Type something to search...
How to improve Core Web Vitals in an Astro site?

How to improve Core Web Vitals in an Astro site?

To improve Core Web Vitals in an Astro site, work on each metric with the tool Astro already gives you. For Largest Contentful Paint (LCP), render the hero image with <Image /> from astro:assets and add the priority prop so it loads eagerly with fetchpriority="high". For Cumulative Layout Shift (CLS), let <Image /> set width and height for every image and use Astro's fonts API, which generates metric-adjusted fallback fonts. For Interaction to Next Paint (INP), ship less JavaScript by hydrating fewer islands and using lazy client directives such as client:visible. Then measure real visitors with the web-vitals library, because lab scores only tell part of the story.

Astro starts from a strong position: pages are HTML by default and JavaScript is opt-in. Even so, a large unoptimised hero image, a late web font or a heavy island can push any of the three metrics into the red. In this article you'll learn what each metric measures and its thresholds, how to fix LCP, CLS and INP with Astro 7 features, how server response time feeds into LCP on on-demand pages, and how to collect field data from your own visitors.

The Three Metrics and Their Thresholds

Google measures Core Web Vitals at the 75th percentile of page loads, split by mobile and desktop. A page passes when 75% of visits fall in the "good" range:

MetricMeasuresGoodNeeds improvementPoor
LCPTime until the largest image or text block is rendered2.5 s or lessUp to 4 sOver 4 s
CLSHow much visible content moves unexpectedly0.1 or lessUp to 0.25Over 0.25
INPDelay between an interaction and the next frame200 ms or lessUp to 500 msOver 500 ms

Each metric has a typical Astro cause:

  • LCP: usually the hero image, a web font delaying the main heading, or slow server response for on-demand pages.
  • CLS: images without dimensions, fonts swapping to different metrics, and content injected above existing content.
  • INP: JavaScript keeping the main thread busy, typically from islands hydrated on load or third-party scripts.

Improving Largest Contentful Paint

On most content sites, the LCP element is the first large image. Your goal is to get that file requested as early as possible, at the right size, in a modern format.

Use the Image Component with priority

The <Image /> component converts local images to WebP by default, generates the right dimensions and, with a layout, a responsive srcset. The priority prop is the key setting for the LCP image:

---
// src/pages/index.astro
import { Image } from "astro:assets";
import Layout from "../layouts/Layout.astro";
import hero from "../assets/hero.png";
---

<Layout title="Home">
  <Image src={hero} alt="Mountains at sunrise" width={1200} priority />
  <h1>Welcome</h1>
</Layout>

With responsive images enabled in the config (shown below), the built <img> looked like this in my test project on Astro 7.4.2:

<img
  src="/_astro/hero.D4qpToCX_17aX4a.webp"
  srcset="/_astro/hero.D4qpToCX_1PvUzF.webp 640w, /_astro/hero.D4qpToCX_Z26Od3g.webp 750w, /_astro/hero.D4qpToCX_2dd0uU.webp 828w, /_astro/hero.D4qpToCX_70MlH.webp 1080w, /_astro/hero.D4qpToCX_17aX4a.webp 1200w, /_astro/hero.D4qpToCX_ZQ4FWT.webp 1280w"
  alt="Mountains at sunrise"
  loading="eager"
  decoding="sync"
  fetchpriority="high"
  sizes="(min-width: 1200px) 1200px, 100vw"
  width="1200"
  height="675"
>

The 1600-pixel PNG source became a set of WebP files, the browser picks the smallest one that fits the screen, and priority replaced the default loading="lazy" with eager loading and high fetch priority. Use priority only on the one or two images that are visible on first paint. Everything else should stay lazy, as covered in the guide to lazy loading in Astro.

Turn On Responsive Images Globally

Rather than setting layout on every image, set a default in the config:

// astro.config.mjs
import { defineConfig } from "astro/config";

export default defineConfig({
  image: {
    layout: "constrained",
    responsiveStyles: true,
  },
});
  • layout: "constrained": images scale down to fit their container but never grow beyond their natural size. Other values are full-width, fixed and none.
  • responsiveStyles: true: adds a small set of global styles so the images actually resize. It's false by default, and the generated srcset won't behave as expected without some styles. If you use Tailwind and prefer its utilities, leave this off and style images yourself.

The global image.layout also applies to local images in Markdown, which matters for blogs where most images come from content files. The Image component guide covers every prop in detail.

Don't Lazy Load the LCP Image

This is the most common LCP mistake. A lazily loaded hero image waits until layout is complete before it's requested, which can add hundreds of milliseconds. If your LCP element is a CSS background image, the browser also discovers it late, because it has to parse the CSS first. Prefer a real <img> for the hero, or add a preload hint if you can't.

Make Text Visible Quickly

When the LCP element is a heading, the font is usually what delays it. Astro's fonts API, stable since Astro 6, downloads fonts at build time, serves them from your own domain and uses font-display: swap by default, so text renders immediately in a fallback font:

// astro.config.mjs
import { defineConfig, fontProviders } from "astro/config";

export default defineConfig({
  fonts: [
    {
      provider: fontProviders.fontsource(),
      name: "Inter",
      cssVariable: "--font-inter",
      weights: [400, 700],
      styles: ["normal"],
      fallbacks: ["sans-serif"],
    },
  ],
});

Add the <Font /> component to your layout's <head> and preload only the file needed above the fold:

---
// src/layouts/Layout.astro
import { Font } from "astro:assets";

const { title } = Astro.props;
---

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
    <Font cssVariable="--font-inter" preload={[{ weight: 400, style: "normal" }]} />
  </head>
  <body>
    <slot />
  </body>
</html>

<style is:global>
  body {
    font-family: var(--font-inter);
  }
</style>

A plain preload with no value preloads every file. In my first build without the styles option, that meant four preload links, including two italic files the page never used. Limiting styles and passing a preload filter reduced it to one:

<link rel="preload" href="/_astro/fonts/07431a16dd76433e.woff2" as="font" type="font/woff2" crossorigin>

See custom fonts in Astro for local files and other providers.

Reducing Cumulative Layout Shift

CLS comes from content moving after it first appears. Three Astro habits remove most of it.

Always Give Images Dimensions

The <Image /> and <Picture /> components read the dimensions of local images and write width and height attributes, so the browser reserves the right space before the file arrives. For remote images, pass width and height yourself, or use inferSize for authorised remote domains. If you write plain <img> tags in components, add the attributes manually.

Use Metric-Adjusted Fallback Fonts

When a web font replaces a fallback with different widths and line heights, text reflows and everything below it moves. The fonts API handles this automatically. It takes the last generic family in fallbacks and generates an adjusted local font to match your web font's metrics. In the test build, it produced this rule:

@font-face {
  font-family: "Inter-28802b531885c8c7 fallback: Arial";
  src: local("Arial");
  font-display: swap;
  font-weight: 400;
  font-style: normal;
  size-adjust: 107.1194%;
  ascent-override: 90.4365%;
  descent-override: 22.518%;
  line-gap-override: 0%;
}

Because the fallback now takes up nearly the same space as Inter, the swap causes little or no visible shift. You can disable this per font with optimizedFallbacks: false, but there's rarely a reason to.

Reserve Space for Late Content

Banners, embeds, ads and anything loaded after first paint should have space reserved in CSS, for example with min-height or aspect-ratio. Server islands, described below, render a fallback first and then swap in the real content, so give the fallback the same size as the content it stands in for.

Also check the view transition and theme scripts in your layout. A theme class applied late can change colours or spacing after paint. Set it from an inline script in the <head> before the body renders.

Improving Interaction to Next Paint

INP depends on how quickly the main thread can respond to clicks, taps and key presses. On an Astro site, the main thread is busy for two reasons: hydrating islands and running third-party scripts.

Hydrate Less, and Later

Every client:load island downloads its framework runtime and component code, then hydrates as soon as possible, competing with the visitor's first interactions. Pick the laziest directive that still works:

DirectiveHydrates whenGood for
client:loadImmediately on page loadControls needed straight away
client:idleThe browser is idleLower-priority widgets
client:visibleThe component scrolls into viewBelow-the-fold carousels, comments
client:mediaA media query matchesMobile-only menus
client:onlyImmediately, rendered only on the clientComponents that can't render on the server
---
// src/pages/post.astro
import Comments from "../components/Comments.jsx";
import ShareMenu from "../components/ShareMenu.jsx";
---

<ShareMenu client:idle />
<Comments client:visible />

Better still, ask whether the component needs a framework at all. A disclosure, a tab set or a copy button can often be a few lines in a <script> tag. The client directives guide covers every option, and reducing JavaScript bundle size goes further into trimming what you ship.

Break Up Long Tasks

If you must run a lot of work after an interaction, yield to the browser so it can paint first. In modern browsers, scheduler.yield() does this; where it isn't available, a setTimeout of zero is the usual fallback:

// src/scripts/yield.ts
export function yieldToMain(): Promise<void> {
  if ("scheduler" in globalThis && "yield" in (globalThis as any).scheduler) {
    return (globalThis as any).scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Call await yieldToMain() between chunks of work in a click handler, after the visible update.

Keep Third-Party Scripts in Check

Analytics, chat widgets and tag managers often cost more INP than your own code. Load them with defer or async, delay non-essential ones until after the first interaction, or move them off the main thread with the @astrojs/partytown integration where the script supports it.

Server Response Time and On-Demand Pages

For prerendered pages, a CDN serves static HTML in milliseconds, so server time rarely matters. For on-demand rendered pages, every millisecond the server spends before sending the first byte is added directly to LCP. Three things help:

  • Prerender what you can: a page that doesn't change per request should be static.
  • Cache rendered responses: Astro 7's stable cache and routeRules config let you cache on-demand pages for a set time, with stale-while-revalidate, as explained in caching on-demand pages.
  • Move slow, personalised parts into server islands: server:defer lets the main page respond immediately while a slow component, such as a user's avatar or recommendations, renders separately afterwards.
---
// src/pages/index.astro
import Recommendations from "../components/Recommendations.astro";
---

<Recommendations server:defer>
  <div slot="fallback" style="min-height: 320px">Loading recommendations...</div>
</Recommendations>

The fallback has a min-height so the page doesn't shift when the real content arrives.

Measuring Real Visitors

Lighthouse and PageSpeed Insights lab tests are useful for debugging, but they simulate one device on one connection. Core Web Vitals are assessed on field data from real visits. You can collect your own with the web-vitals library:

npm install web-vitals
<script>
  // src/components/WebVitals.astro
  import { onCLS, onINP, onLCP } from "web-vitals";

  function send(metric: { name: string; value: number; rating: string }) {
    const body = JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      page: location.pathname,
    });
    navigator.sendBeacon("/api/vitals", body);
  }

  onCLS(send);
  onINP(send);
  onLCP(send);
</script>

Include the component once in your layout. Astro bundles the script as a module, and in my test the whole bundle came to 8,077 bytes, or about 3.2 KB gzipped. The /api/vitals endpoint can be an Astro API route that writes to your analytics store; with no endpoint, replace sendBeacon with console.log while you test locally.

A practical workflow looks like this:

  1. Check field data first: PageSpeed Insights shows Chrome UX Report data for your URLs when there is enough traffic.
  2. Find the failing metric and page type: blog posts, listing pages and the home page often fail for different reasons.
  3. Reproduce in the lab: use Lighthouse or the Performance panel in DevTools on a throttled mobile profile.
  4. Fix and deploy: then wait. Field data is a rolling 28-day window, so improvements take a few weeks to show fully.

FAQ: Core Web Vitals in Astro

Yes. Astro sends HTML with no JavaScript by default, which helps all three metrics, especially INP. Good scores still depend on optimised images, well-loaded fonts and keeping hydrated islands to a minimum.

It sets loading to eager, decoding to sync and fetchpriority to high on the rendered img element. Use it on the image that is likely to be the Largest Contentful Paint element, usually the hero, and nowhere else.

Use the built-in fonts API. It generates a fallback font with adjusted size and line metrics from the last generic family in your fallbacks list, so the swap to the web font moves very little text.

The laziest one that works. client:visible and client:idle delay hydration so the main thread is free for early interactions. Components that don't need interactivity shouldn't have a client directive at all.

Lab tests run one simulated device and connection. Real visitors use slower phones, poor networks and interact with the page, which lab tests don't capture well, especially for INP. Collect field data with the web-vitals library or check the Chrome UX Report.

They can, because server rendering time is added before the browser receives any HTML. Prerender pages that don't change per request, cache on-demand responses with Astro's cache config, and move slow personalised parts into server islands.


Conclusion

Astro's defaults remove the biggest source of poor Core Web Vitals, which is unnecessary JavaScript. What's left is mostly about images, fonts and the islands you choose to hydrate. Use <Image /> with priority for the hero and responsive layouts for the rest, let the fonts API self-host and preload your critical font with matched fallbacks, and hydrate only what truly needs to be interactive, as late as you can.

For on-demand pages, keep server response fast with prerendering, caching and server islands. Then measure what real visitors experience with web-vitals, fix the page types that fail, and give field data a few weeks to catch up. Treated that way, passing all three metrics becomes the normal state for an Astro site rather than a one-off project.

Tags :
Share :

Related Posts

What are Astro Actions and how do they work?

What are Astro Actions and how do they work?

Astro Actions are type-safe backend functions that you define once on the server and then call from the browser as if they were ordinary async fu

Dive Deeper
What are Astro adapters and which one should you choose?

What are Astro adapters and which one should you choose?

An Astro adapter is an integration that builds the server part of your project for a specific runtime, such as Node.js, Vercel Functions, Netlify

Dive Deeper
How to build API endpoints in Astro?

How to build API endpoints in Astro?

To build an API endpoint in Astro, create a .js or .ts file in src/pages/ and export a function named after an HTTP method, such as GET or

Dive Deeper