Performance

Web Performance Optimization Techniques

Optimize your website performance and improve user experience with proven techniques.

Plake
Mar 20, 20268 min read1,404 words
Web Performance Optimization Techniques

Performance is a feature. A site that takes three seconds to load converts dramatically worse than one that takes one second — and that gap widens further on mobile networks. This guide cuts through the noise and focuses on the techniques with the highest real-world impact, in roughly the order you should reach for them.

Core Web Vitals: The Metrics That Matter

Google’s Core Web Vitals are the metrics that correlate most with user experience and directly influence search rankings. Optimise for these three first.

Metric What it measures Good threshold
LCP (Largest Contentful Paint) Load time of largest visible element < 2.5s
INP (Interaction to Next Paint) Responsiveness to user input < 200ms
CLS (Cumulative Layout Shift) Visual stability during load < 0.1

Measure before you optimise:

# Run Lighthouse from the CLI against a URL
npx lighthouse https://example.com --view

# Or use the Chrome DevTools Performance panel
# DevTools → Performance → Record → reload → stop

Use web-vitals to track real-user data:

import { onLCP, onINP, onCLS } from "web-vitals";

function sendToAnalytics({ name, value, id }) {
  fetch("/api/vitals", {
    method: "POST",
    body: JSON.stringify({ name, value, id }),
    keepalive: true,
  });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

Image Optimization

Images are the single largest contributor to page weight on most sites. Modern formats and lazy loading are the two fastest wins.

Use WebP and AVIF. They deliver equivalent visual quality at 25–50% smaller file sizes than JPEG:

<!-- Manual progressive enhancement -->
<picture>
  <source srcset="hero.avif" type="image/avif" />
  <source srcset="hero.webp" type="image/webp" />
  <img src="hero.jpg" alt="Hero image" width="1200" height="600" />
</picture>

In Astro, the <Image> component handles this automatically:

---
import { Image } from "astro:assets";
import hero from "../assets/hero.webp";
---

<!-- Hero image — eager-load it to improve LCP -->
<Image
  src={hero}
  alt="Product showcase"
  width={1200}
  height={600}
  loading="eager"
  sizes="100vw"
/>

<!-- Below-fold content — lazy load (the default) -->
<Image
  src={product.thumbnail}
  alt={product.name}
  width={400}
  height={300}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

Always specify width and height. Without them, the browser doesn’t know the image’s aspect ratio before it downloads, causing layout shift (CLS).

Responsive images with srcset:

<img
  src="image-800.webp"
  srcset="image-400.webp 400w, image-800.webp 800w, image-1600.webp 1600w"
  sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1600px"
  alt="Description"
/>

The browser chooses the smallest image that fills the display slot.

JavaScript Bundle Optimization

Large JavaScript bundles block the main thread and delay interactivity. Strategies:

Code Splitting

Astro splits code per page automatically and ships zero JavaScript unless you ask for it. Interactive widgets become islands, hydrated only when needed via client:* directives:

---
import AnalyticsChart from "../components/AnalyticsChart.jsx";
---

<!-- Hydrate only when the component scrolls into view -->
<AnalyticsChart client:visible />

For heavy modules used inside an island, defer the import until it’s actually needed:

// Load the chart library only when the panel opens
const renderChart = async () => {
  const { Chart } = await import("chart.js");
  return new Chart(/* … */);
};

Analyse Your Bundle

npm install --save-dev rollup-plugin-visualizer

# astro.config.mjs
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  vite: {
    plugins: [visualizer({ open: true, gzipSize: true })],
  },
});

# Run
npm run build

The treemap shows exactly which packages are eating your budget. Common culprits: moment.js (replace with date-fns), lodash (use ES modules or native methods), heavyweight charting libraries.

Tree Shaking

Import named exports, not whole modules:

// Bad — imports the entire lodash bundle (~70kB)
import _ from "lodash";
_.debounce(fn, 300);

// Good — imports only debounce (~2kB)
import debounce from "lodash/debounce";

// Best — native works in modern environments
function debounce(fn: Function, delay: number) {
  let timer: ReturnType<typeof setTimeout>;
  return (...args: unknown[]) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

Lazy Loading Components

Defer loading below-fold content until it’s needed:

// LazySection.jsx — a React island, hydrated with client:visible
import { useEffect, useRef, useState } from "react";

function LazySection({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) setVisible(true);
      },
      { rootMargin: "200px" }, // load slightly before it enters the viewport
    );
    if (ref.current) observer.observe(ref.current);
    return () => observer.disconnect();
  }, []);

  return <div ref={ref}>{visible ? children : <Skeleton />}</div>;
}

Fonts

Fonts are a frequent CLS and LCP culprit.

---
// src/layouts/Layout.astro — Astro's experimental Fonts API
import { Font } from "astro:assets";
---

<head>
  <Font cssVariable="--font-inter" preload />
</head>
// astro.config.mjs
import { defineConfig, fontProviders } from "astro/config";

export default defineConfig({
  experimental: {
    fonts: [
      {
        provider: fontProviders.google(),
        name: "Inter",
        cssVariable: "--font-inter",
        // FOUT over FOIT — content visible immediately
        fallbacks: ["sans-serif"],
      },
    ],
  },
});

Astro downloads the font at build time, self-hosts it, and emits the @font-face declaration with optimised fallbacks — zero external DNS lookup, zero CORS, automatic font-display: swap.

Caching Strategies

The fastest request is one that never leaves the browser.

HTTP Cache Headers

// Route handler with aggressive caching for static assets
export async function GET() {
  const data = await getPublicData();
  return Response.json(data, {
    headers: {
      "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
    },
  });
}

On-Demand Rendering in Astro

---
// src/pages/products/[id].astro
export const prerender = false; // render fresh on each request

const product = await fetchProduct(Astro.params.id);

// Let the CDN cache the response for a minute
Astro.response.headers.set(
  "Cache-Control",
  "public, s-maxage=60, stale-while-revalidate=86400",
);
---

<ProductView product={product} />

Service Worker / Offline Cache

// public/sw.js
const CACHE_NAME = "v1";
const PRECACHE = ["/", "/offline.html"];

self.addEventListener("install", (e) => {
  e.waitUntil(caches.open(CACHE_NAME).then((c) => c.addAll(PRECACHE)));
});

self.addEventListener("fetch", (e) => {
  e.respondWith(
    caches.match(e.request).then((cached) => cached || fetch(e.request)),
  );
});

Critical CSS

Inline the styles needed for above-the-fold content. Astro and Tailwind handle this automatically — Tailwind ships only used utilities, and Astro scopes component styles and inlines small stylesheets via <style> tags in the <head>.

Tune the threshold for inlining stylesheets in your config:

// astro.config.mjs
export default defineConfig({
  build: {
    // inline all stylesheets, or "auto" to inline only small ones
    inlineStylesheets: "auto",
  },
});

Reducing Layout Shift (CLS)

Layout shift happens when elements move after the initial render. Common causes and fixes:

  • Images without dimensions → always specify width/height or use aspect-ratio.
  • Dynamic content injected above existing content → reserve space with a skeleton or min-height.
  • Web fonts swapping → use font-display: swap and pre-load critical fonts.
  • Ads and embeds → give them a fixed-size container.
/* Reserve space for a 16:9 embed */
.video-container {
  aspect-ratio: 16 / 9;
  width: 100%;
}

Web performance dashboard illustration

Database and API Performance

Front-end optimisation has limits. Slow APIs show up as long TTFB (Time to First Byte) in Lighthouse.

// Add timing headers to trace slow routes
export async function GET(request: Request) {
  const start = performance.now();
  const data = await fetchData();
  const duration = performance.now() - start;

  return Response.json(data, {
    headers: {
      "Server-Timing": `db;dur=${duration.toFixed(1)}`,
    },
  });
}

Visible in Chrome DevTools → Network → Headers → Server-Timing.

Common API wins:

  • Add database indexes on query predicates (see the Database Design Patterns post).
  • Use HTTP/2 or HTTP/3 — multiplexed requests, no head-of-line blocking.
  • Serve from the edge (Cloudflare Workers, Vercel Edge Functions) for geographically distributed users.

Continuous Performance Monitoring

Performance regressions ship silently. Automate detection:

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: treosh/lighthouse-ci-action@v11
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/blog
          budgetPath: .lighthouserc.json
          uploadArtifacts: true
// .lighthouserc.json
{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "first-contentful-paint": ["error", { "maxNumericValue": 2000 }]
      }
    }
  }
}

Failed Lighthouse budgets block the PR. Performance stays in the contract.

Closing Thoughts

Performance optimisation follows a clear priority order: measure first, then fix images, then JavaScript, then fonts, then everything else. Most sites get 80% of available gains from images and bundle size alone. The other 20% — edge caching, service workers, fine-tuned ISR — is worth pursuing once the fundamentals are solid. Build the habit of running Lighthouse before you ship, not after.

Share:

About the author

The Plake Team

Writes long-form essays on craft, software, and the slow web. Curates the Sunday digest read by 88,000+ readers.

Read more

Keep reading

Related Articles

Back to AllBack to All
Complete SEO Optimization Guide for Astro Applications

Mar 1, 2026

7 min

Complete SEO Optimization Guide for Astro Applications

Getting Started with Astro 5: A Beginner's Guide

Feb 15, 2026

6 min

Getting Started with Astro 5: A Beginner's Guide