
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:
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | Time until the largest image or text block is rendered | 2.5 s or less | Up to 4 s | Over 4 s |
| CLS | How much visible content moves unexpectedly | 0.1 or less | Up to 0.25 | Over 0.25 |
| INP | Delay between an interaction and the next frame | 200 ms or less | Up to 500 ms | Over 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,fixedandnone. - responsiveStyles: true: adds a small set of global styles so the images actually resize. It's
falseby default, and the generatedsrcsetwon'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:
| Directive | Hydrates when | Good for |
|---|---|---|
client:load | Immediately on page load | Controls needed straight away |
client:idle | The browser is idle | Lower-priority widgets |
client:visible | The component scrolls into view | Below-the-fold carousels, comments |
client:media | A media query matches | Mobile-only menus |
client:only | Immediately, rendered only on the client | Components 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
cacheandrouteRulesconfig 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:deferlets 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:
- Check field data first: PageSpeed Insights shows Chrome UX Report data for your URLs when there is enough traffic.
- Find the failing metric and page type: blog posts, listing pages and the home page often fail for different reasons.
- Reproduce in the lab: use Lighthouse or the Performance panel in DevTools on a throttled mobile profile.
- 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.


