Web performance directly impacts user experience, conversion rates, and search rankings. Slow pages lose visitors before they ever see your content, and every extra second of waiting erodes trust. Here is how to make your site fast and keep it that way.
This guide works from measurement outward: first decide what “fast” means, then fix the biggest offenders (images, fonts, JavaScript), then make the gains durable with caching and monitoring.
Core Web Vitals
Google’s Core Web Vitals — Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS) — provide measurable benchmarks for user experience. Target LCP under 2.5 seconds, INP under 200 milliseconds, and CLS under 0.1. INP replaced First Input Delay (FID) as a Core Web Vital in March 2024, and it is a much stricter metric: FID only measured the delay before the first interaction was handled, while INP looks at the latency of interactions across the whole visit.
The thresholds are assessed at the 75th percentile of page loads, split by mobile and desktop. That detail matters. A page that is fast on your development laptop over office Wi-Fi can still fail if a quarter of your real visitors are on mid-range Android phones on a congested network.
What each metric is really telling you
- LCP measures when the largest image or text block in the viewport finishes rendering. It is usually a hero image, a heading, or a large paragraph. Poor LCP is most often caused by slow server response, render-blocking CSS and JavaScript, or a hero image that the browser discovers too late.
- INP measures how quickly the page visually responds after a click, tap, or key press. Poor INP almost always means the main thread is busy: long JavaScript tasks, heavy hydration, or expensive event handlers.
- CLS measures unexpected movement of visible content. The usual culprits are images and embeds without dimensions, web fonts that swap in with different metrics, and banners or ads injected above existing content.
Lab data versus field data
Lab tools such as Lighthouse run a single synthetic load on a simulated device. They are excellent for debugging and for catching regressions in a pull request, but they cannot measure INP directly because nobody is interacting with the page. Field data, collected from real visitors, is what the Core Web Vitals assessment is actually based on. Use lab data to find problems and field data to decide which problems matter.
Start With the Server
No amount of front-end tuning can hide a slow first byte. Time to First Byte (TTFB) is not a Core Web Vital, but it sits directly in front of LCP: the browser cannot start rendering until HTML arrives.
- Pre-render what you can. Static generation serves HTML straight from a CDN with no origin round trip. For pages that genuinely need per-request data, cache the rendered output at the edge with a short lifetime rather than rendering every request from scratch.
- Stream HTML. When server rendering is unavoidable, flush the document head early so the browser can start fetching CSS and fonts while the body is still being generated.
- Avoid redirect chains. Every redirect is a full round trip before any content loads. Link to canonical URLs directly and keep trailing-slash and
wwwrules consistent. - Enable modern protocols and compression. HTTP/2 or HTTP/3 and Brotli or gzip compression for text assets are table stakes for any serious host or CDN.
Image Optimization
Images remain the largest contributor to page weight on most sites. Use modern formats like WebP and AVIF with appropriate fallbacks. Implement responsive images with srcset to serve appropriately sized images for each device. Lazy-load images below the fold using the native loading="lazy" attribute.
A well-formed responsive image looks like this:
<img
src="/images/dashboard-800.webp"
srcset="/images/dashboard-400.webp 400w,
/images/dashboard-800.webp 800w,
/images/dashboard-1600.webp 1600w"
sizes="(min-width: 1024px) 50vw, 100vw"
width="1600"
height="900"
alt="Revenue dashboard showing monthly trends"
loading="lazy"
decoding="async"
/>
The width and height attributes let the browser reserve the correct aspect ratio before the file downloads, which removes a common source of layout shift. The sizes attribute tells the browser how wide the image will render so it can pick the smallest suitable candidate from srcset.
Treat the LCP image differently
Lazy loading is the wrong choice for the image most likely to be your LCP element. A lazy hero image waits for layout before it starts downloading, which directly delays LCP. For the hero:
- Remove
loading="lazy". - Add
fetchpriority="high"so the browser fetches it ahead of other images. - Make sure it is discoverable in the initial HTML, not set as a CSS background or injected by JavaScript.
- If it must come from CSS or a late-loading component, add a
<link rel="preload" as="image">in the document head.
If you use Astro, the built-in <Image /> and <Picture /> components generate optimized formats, dimensions, and srcset values at build time, which removes most of the manual bookkeeping.
Fonts Without the Flash
Web fonts mainly affect LCP and CLS. A font that blocks text rendering delays LCP when the LCP element is text, and a fallback font that swaps to a web font with different metrics causes layout shift.
- Self-host your fonts so they come from the same origin and connection as your page, avoiding an extra DNS lookup and TLS handshake.
- Subset fonts to the character sets you actually use. A Latin-only subset is a fraction of the size of a full multilingual file.
- Use WOFF2 exclusively; every modern browser supports it.
- Preload only the one or two font files used above the fold.
- Match fallback metrics with the
size-adjust,ascent-override, anddescent-overridedescriptors so the swap is visually close to invisible.
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
}
body {
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}
The exact override values depend on the font pair; several open-source tools can calculate them for you.
Code Splitting
Only ship the JavaScript a page actually needs. Frameworks like Astro handle this automatically through island architecture, but for client-heavy applications, dynamic imports and route-based splitting reduce initial bundle sizes significantly.
Bytes are only half the cost. JavaScript must also be parsed, compiled, and executed on the main thread, and on a mid-range phone that work can take several times longer than on a developer machine. That is why large bundles show up as poor INP, not just slow loads.
Load interactive code when it is needed
Defer anything that is not required for the first interaction. A chart library used in a modal does not belong in the initial bundle:
const openReport = async () => {
const { renderChart } = await import("./charts/revenue-chart");
renderChart(document.querySelector("#report")!);
};
button.addEventListener("click", openReport);
In Astro, choose the hydration directive deliberately. client:load hydrates immediately, client:idle waits until the browser is idle, and client:visible waits until the component scrolls into view. A comment widget at the bottom of an article almost never needs client:load.
Audit your dependencies
Run a bundle analyzer on every production build and look for the usual suspects: a full utility library imported for one function, date libraries bundling every locale, duplicate copies of the same package at different versions, and polyfills for browsers you no longer support. Replacing one heavy dependency often saves more than weeks of micro-optimization.
Keep the Main Thread Free
INP problems are main-thread problems. Any task that runs longer than 50 milliseconds is a long task, and while it runs the browser cannot respond to input.
- Break up long tasks. Split large loops and initialization routines into chunks and yield back to the browser between them.
scheduler.yield()is available in Chromium-based browsers and Firefox; feature-detect it and fall back tosetTimeoutelsewhere. - Do less in event handlers. Update the UI immediately, then defer analytics calls, persistence, and non-visible work.
- Avoid layout thrashing. Reading layout properties such as
offsetHeightafter writing styles forces synchronous layout. Batch reads before writes. - Move heavy computation off the main thread. Parsing large JSON payloads, filtering big datasets, or image processing can run in a Web Worker.
- Watch third-party scripts. Tag managers, chat widgets, and A/B testing tools frequently cause the worst long tasks on a page. Load them after the page is interactive, and question whether each one still earns its cost.
async function processInChunks<T>(items: T[], work: (item: T) => void) {
for (let i = 0; i < items.length; i++) {
work(items[i]!);
if (i % 100 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
}
CSS and Rendering
Stylesheets in the document head are render-blocking by design. That is usually correct, because you do not want unstyled content to flash, but it means CSS size directly affects LCP.
- Keep critical CSS small. Utility-first frameworks that generate only the classes you use help here.
- Avoid
@importinside CSS files, which creates sequential request chains. - Use
content-visibility: autoon long, below-the-fold sections so the browser can skip rendering work until they approach the viewport. Pair it withcontain-intrinsic-sizeto avoid scrollbar jumps. - Animate
transformandopacityrather than properties such astop,width, orheight, which trigger layout on every frame.
Caching Strategies
Implement a layered caching approach: browser cache for static assets with long max-age headers, CDN cache at the edge for geographically distributed content, and application cache for computed or database-driven data. Use cache-busting with content hashes for immutable assets.
A practical set of headers covers most sites:
# Hashed build assets: never change, cache forever
Cache-Control: public, max-age=31536000, immutable
# HTML: always revalidate so deploys show up immediately
Cache-Control: public, max-age=0, must-revalidate
# Semi-dynamic API responses: serve stale while refreshing
Cache-Control: public, max-age=60, stale-while-revalidate=300
The key rule is that only content-hashed files get long lifetimes. If a filename can be reused for different content, a long max-age will strand users on old versions after a deploy.
Edge and application caching
At the CDN layer, cache HTML for anonymous visitors whenever the content allows it, and use surrogate keys or tag-based purging if your provider supports it so a content update invalidates exactly the pages it affects. At the application layer, cache expensive queries and computed fragments, and be explicit about invalidation. A cache without a clear invalidation strategy eventually serves wrong data, which is worse than serving slow data.
Speed up the next page
Once the current page is fast, make navigation feel instant. Prefetch likely next pages on hover or when links enter the viewport; Astro includes a configurable prefetch feature for exactly this. Make sure pages are eligible for the browser’s back/forward cache by avoiding unload handlers and Cache-Control: no-store on HTML that does not need it.
Set a Performance Budget
Fixes regress without guardrails. A performance budget turns “keep it fast” into numbers a build can fail on:
- Maximum JavaScript shipped per route, compressed.
- Maximum total image weight on key landing pages.
- Lighthouse performance score floor on representative pages.
- Field targets: LCP, INP, and CLS at the 75th percentile within the “good” thresholds.
Check the lab-based budgets in your local or CI gate on every change, and review the field targets on a regular cadence. When a budget is exceeded, treat it like a failing test: either fix the regression or make an explicit, documented decision to raise the limit.
Monitoring
Performance optimization is not a one-time task. Set up real user monitoring (RUM) to track performance metrics from actual user sessions. Commercial RUM services or the open-source web-vitals JavaScript library provide ongoing visibility into how real users experience your site.
Collecting field data yourself takes only a few lines:
import { onCLS, onINP, onLCP } from "web-vitals";
function send(metric: { name: string; value: number; id: string }) {
navigator.sendBeacon("/api/vitals", JSON.stringify(metric));
}
onCLS(send);
onINP(send);
onLCP(send);
Segment the data by page template, device type, and connection where you can. An average across the whole site hides the one template that fails for most of its visitors. The library’s attribution build can also report which element was the LCP candidate and which interaction caused a slow INP, which turns a vague “the site is slow” into a specific fix.
Closing Thoughts
Fast sites are rarely the result of one clever trick. They come from a short list of habits applied consistently: measure with real-user data, serve HTML quickly, prioritize the LCP resource, ship less JavaScript, keep the main thread free, cache deliberately, and guard the results with a budget. Start with whichever metric is furthest from its threshold on your most important pages, fix the largest cause, measure again, and repeat.