Performance Optimization & Core Web Vitals for SSGs

Static site generators give you a head start on Core Web Vitals — pre-rendered HTML means fast Largest Contentful Paint (LCP) and stable layout by default. But production performance isn't automatic. It comes from disciplined asset processing, fast incremental builds, correct cache headers at the edge, and keeping JavaScript off pages that don't need it. This guide is for engineers, indie hackers, and documentation teams who already ship a static site and want to turn "fast by default" into "fast under real-world load."

We cover the levers in order of impact: how the generator you pick sets the baseline, what the build produces, how the edge delivers it, how much JavaScript runs on the client, and how you keep all of it from regressing. Each one ties back to a specific metric and a specific tool you can use to measure it.

From build to browser: where each Core Web Vital is won A four-stage pipeline showing build-time asset optimization, edge CDN delivery, client hydration, and continuous measurement, each annotated with the Core Web Vital it most affects. Build Assets, images, bundles → LCP ceiling Edge CDN cache & headers → TTFB Hydrate Islands, defer third-party JS → INP Measure Lab + field RUM budgets → CLS & drift Each stage owns a different Core Web Vital A regression in any one stage shows up in production field data, not the lab.
The build sets your LCP ceiling, the edge sets TTFB, hydration governs INP, and measurement catches CLS and slow drift.

What You Will Learn

This guide is organized around the stages of static-site performance, each with its own deep-dive companion:

Choosing an SSG for Production Performance

Every major generator pre-renders HTML, but they differ in two dimensions that decide your Core Web Vitals baseline: how much JavaScript reaches the browser, and how much asset processing happens for you at build time.

Astro ships zero JS by default and hydrates islands on demand, which gives it the best out-of-the-box INP story for content sites. Eleventy is template-only — there is no client runtime at all unless you add one — so its baseline is even leaner, at the cost of doing interactivity yourself. Hugo is the fastest builder and emits plain HTML/CSS, ideal for large content repositories. A Next.js static export gives you React's component model with pre-rendered output, but you pay for the framework runtime on every interactive page, which is the single biggest lever on INP.

GeneratorDefault client JSBuild speedBest fit
EleventyNone (opt-in)FastLean content, full control of JS
HugoNoneFastestVery large content repositories
AstroZero, islands on demandModerateContent sites needing selective interactivity
Next.js exportFull React hydrationSlowerTeams committed to the React ecosystem

For Core Web Vitals specifically, the ranking on a content-heavy site is roughly: Eleventy and Hugo (no runtime) ≈ Astro (islands) < Next export (full React hydration). The trade-off is authoring experience and ecosystem, not raw output speed — that comparison is weighed page by page in Choosing the Right Static Site Generator for Production, and framework build times specifically in Hugo Build Times for Large Repositories. If you are still narrowing the field, the SSG framework selection matrix scores each option against production criteria.

Build Performance & Incremental Builds at Scale

Build speed is not a Core Web Vital, but it decides whether you actually measure them. When a full rebuild takes ten minutes, teams stop previewing changes and skip the Lighthouse pass that catches regressions. Keeping builds fast is what keeps the measure-and-fix loop tight enough to protect the metrics that ship.

The problem scales with content. A 200-page site rebuilds in seconds; a 20,000-page documentation set can take many minutes if every page is regenerated on every commit. Three techniques keep that in check:

  • Incremental builds — only regenerate pages whose source (or a shared dependency) changed. Hugo and Eleventy both support incremental modes; Next.js offers Incremental Static Regeneration for hybrid hosts.
  • Persistent build caching — cache the expensive steps (image transforms, MDX compilation, data fetches) between runs so a warm build skips work a cold build repeats. In CI this means restoring a cache key keyed on lockfile and content hashes.
  • Parallelism — shard the build across cores or across CI runners for very large sites, then stitch the output.
Full rebuild versus incremental rebuild as a site grows Grouped horizontal bars for three site sizes. A small 200-page site rebuilds fully in about 12 seconds versus 3 seconds incrementally; a medium 2,000-page site in 90 versus 10 seconds; a large 20,000-page site in about 8 minutes versus 45 seconds. The full-rebuild bar grows with the whole site while the incremental bar barely moves. Full rebuild vs incremental rebuild as a site grows Full rebuild (cold cache) Incremental rebuild (warm cache) Small site 200 pages 12s 3s Medium site 2,000 pages 90s 10s Large site 20,000 pages ≈8 min 45s Incremental rebuilds touch only changed pages, so their time barely grows as the full rebuild balloons.
As page count climbs, a cold full rebuild grows with the whole site while a warm incremental rebuild stays nearly flat — the difference between previewing every pull request and previewing none.

The payoff is concrete. A documentation site that took 8 minutes for a clean rebuild in CI can drop to under 45 seconds on a warm cache when only a handful of pages changed — the difference between previewing every pull request and previewing none. The mechanics of cache keys, safe invalidation, and CI wiring are covered end to end in Incremental Builds & Build Caching for SSGs. Because the build step also produces every optimized asset, it is where your LCP ceiling is set — which is the next section.

Build-Time Asset Optimization & Compression

What you ship at build time sets your LCP ceiling. Compress HTML, CSS, and JavaScript — Brotli is well supported by every major CDN and beats gzip by 15-20% on text assets — and split bundles so a visitor to one page doesn't download the whole site's JavaScript.

Generate responsive images at build time rather than resizing in the browser. Each framework has a native path: Hugo's image Resize/Fill methods, Eleventy's eleventy-img, Jekyll's jekyll_picture_tag, and Astro's built-in <Image /> from astro:assets (no separate plugin — image handling moved into Astro core in v3). All emit optimized formats like WebP/AVIF at build time, which typically cut image bytes by 25-50% versus JPEG at equivalent quality.

For JavaScript, the highest-leverage build setting is chunk splitting — isolate dependencies into a long-cacheable vendor chunk so app changes don't bust the whole bundle:

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

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // Put everything from node_modules in a stable "vendor" chunk.
        manualChunks(id) {
          if (id.includes('node_modules')) return 'vendor';
        },
      },
    },
  },
});

A stable vendor chunk pairs directly with the caching strategy below: once it is fingerprinted, a returning visitor never downloads it again until a dependency actually changes.

Core Web Vitals: LCP, CLS and INP for Static Sites

The three metrics fail for different reasons, so they need different fixes:

  • LCP is usually a hero image or a web font blocking the largest text block. Preload the LCP image, serve it as AVIF/WebP, and give it fetchpriority="high". On a typical marketing hero this moves LCP from ~3.2s to ~1.8s on a mid-tier mobile connection. Because it is the metric with the most moving parts on a static page, it gets its own walkthrough in Largest Contentful Paint Optimization for Static Sites.
  • CLS comes from images and ads without reserved dimensions, and from web fonts swapping in late. Always set width/height (or aspect-ratio) on media, and use font-display: optional or a metric-matched fallback to avoid reflow.
  • INP is driven by main-thread JavaScript during interaction. The fix is structural: ship less JS, hydrate fewer components, and move heavy work off the main thread.

The thresholds Google uses for a "good" experience at the 75th percentile are worth committing to memory, because they are what field data is graded against:

MetricGoodNeeds workPoorPrimary driver on an SSG
LCP≤ 2.5s≤ 4.0s> 4.0sHero image / font, TTFB
CLS≤ 0.1≤ 0.25> 0.25Unsized media, late fonts
INP≤ 200ms≤ 500ms> 500msMain-thread JS during interaction

The static-site advantage is that pre-rendering hands you good LCP and CLS almost for free — the largest paint is real HTML the CDN already holds, and there is no client-side layout thrash if you reserve space. That leaves INP and TTFB as the metrics that take real engineering, and they are governed by the two stages that follow: edge delivery and hydration.

Edge/CDN Delivery, Caching and Headers

Serving pre-built HTML well is mostly about cache headers. Distribute through a CDN's points of presence to cut Time to First Byte (TTFB), and align your purge strategy with the CDN Caching Rules for SSGs so a deploy doesn't stampede your origin.

The rule that matters most is the two-tier policy: fingerprinted (hashed) assets never change, so cache them for a year as immutable — the browser then skips revalidation entirely. HTML is the opposite. It needs a short TTL so a new deploy is visible quickly and rollbacks actually take effect, ideally paired with stale-while-revalidate so users get an instant response while the edge refreshes in the background.

Two-tier cache policy at the edge A browser request reaches the CDN edge, which splits into two lanes. Fingerprinted assets are served straight from an immutable one-year edge cache with no origin trip. HTML documents use a short max-age with stale-while-revalidate, returning instantly from the edge while revalidating against the origin in the background. Two-tier cache policy: one edge, two lifetimes Browser request CDN edge nearest PoP Fingerprinted assets max-age=31536000, immutable served from edge — no origin trip repeat visits ≈ free HTML documents max-age=0, s-maxage=300 stale-while-revalidate=86400 instant response, refreshed in background Origin revalidate Hashed asset URLs change on every deploy, so they cache forever; short-lived HTML keeps deploys and rollbacks instant.
Hashed assets are served straight from the immutable edge cache; HTML always revalidates against the origin behind a short TTL, so a new deploy or rollback is visible at once.

A typical host config for hashed assets:

{
  "headers": [
    {
      "source": "/static/:path*",
      "headers": [
        { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
      ]
    }
  ]
}

And the matching short-lived policy for HTML documents:

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400

On platforms with a global edge — the setup in Cloudflare Pages Edge Caching Setup is a worked example — this two-tier split is what turns a fast build into a fast first byte for a visitor on the other side of the world. Get the split wrong in the other direction (long TTL on HTML) and you serve stale content and break rollbacks; that failure mode is in the pitfalls below.

Image, Font and Asset Optimization Pipelines

Images and fonts are the two assets most likely to wreck LCP and CLS. The discipline is the same for both: process them at build time, serve modern formats, and reserve their space in the layout before they load.

For images, that means a build step that emits multiple widths and formats and a <picture> element that lets the browser pick the smallest file that fits the layout slot. A single 1.4 MB JPEG hero can become a set of AVIF sources totaling under 200 KB at the sizes actually rendered — the entire technique, with before/after numbers, is in Image Optimization Pipelines in Astro.

For fonts, it means subsetting to the characters you use, self-hosting to avoid a third-party connection, and preloading the one weight above the fold. Late-swapping fonts are a leading cause of CLS on otherwise-static pages; Font Loading Strategies for Static Sites walks through font-display, metric-matched fallbacks, and the size-adjust descriptor that removes the reflow entirely. In both cases the work happens in the build step above and is delivered by the caching layer above that — the pipeline is one continuous chain, not three isolated optimizations.

Client-Side Hydration & Interaction Metrics

JavaScript on the main thread is what drives INP up. The static-site advantage is that you can ship zero JS by default and hydrate only the components that are actually interactive — islands architecture. Astro's client:* directives make the trade-off explicit: load eagerly, on visibility, or on idle.

<!-- Hydrate only when needed, not on every page load -->
<SearchBox client:visible />
<CartWidget client:idle />

The right directive depends on where the component sits and how soon a user is likely to touch it — client:idle for below-the-fold widgets, client:visible for anything that enters on scroll, and eager loading reserved for controls above the fold. The full decision tree is in JavaScript Hydration & Partial Rendering.

Audit third-party scripts just as hard. Analytics, chat widgets, and ad tags routinely wreck INP on otherwise-fast static pages, because they run on the same main thread your interactions need. Give them a byte budget, load them with async/defer or from a partytown-style worker, and treat a new tag the same way you would treat adding a heavy dependency to your own bundle.

CI/CD, Performance Budgets and Continuous Measurement

Lab scores (Lighthouse) and field data (Real User Monitoring) measure different things; track both. Lab catches regressions before deploy; RUM tells you what users actually experience across devices and networks. The web-vitals library reports LCP, CLS, and INP straight from real sessions, which is the only place INP can be measured honestly — it depends on real interactions the lab cannot simulate.

Gate deploys on a Lighthouse budget so a regression fails the build instead of shipping:

lhci autorun \
  --collect.url=https://deploy-preview.example.com/ \
  --assert.preset=lighthouse:recommended

Running that against a per-branch preview URL — the pattern in Preview Environments for Pull Requests — means every change is measured on production-like infrastructure before it merges. Wire the collection step itself into your pipeline with GitHub Actions for Automated SSG Builds, and the wider deploy, rollback, and content workflow is covered in Production-Ready Deployment & CI/CD Workflows.

When a field metric drops, line it up against your deploy timeline — a sudden INP regression usually maps to a specific release or a new third-party tag, not gradual drift. That single habit, correlating RUM against releases, catches the majority of production regressions before users complain.

Common Pitfalls

  • Over-hydrating static content: attaching a client framework to purely presentational markup adds bundle weight and delays First Contentful Paint with no benefit.
  • Long TTLs on HTML: caching HTML aggressively serves stale content and breaks rollbacks. Keep HTML short-lived; reserve immutable for hashed assets.
  • Ignoring third-party scripts: analytics, chat, and ad tags execute on the main thread and degrade INP and LCP regardless of how optimized your own output is.
  • Unsized media: images, embeds, and ads without explicit dimensions are the most common CLS source on otherwise-static pages.
  • Rebuilding everything on every commit: slow full rebuilds push teams to stop previewing changes, which is how performance regressions slip through unmeasured.
  • Trusting lab scores alone: a 100 in Lighthouse says nothing about the INP a real user hits on a mid-range Android — always confirm against field RUM.

Where the Time Actually Goes on a Static Page

It is worth being concrete about the budget, because "make it fast" is not actionable and the components are not equally expensive.

On a typical content page loaded on a mid-tier phone over a slow connection, the largest single cost is almost always the hero image: it is the LCP element, it is measured in hundreds of kilobytes, and it is downloaded before anything below the fold. The second is render-blocking CSS, which delays every paint by a full round trip regardless of how small the page is. The third is fonts, which delay readable text and, without metric-matched fallbacks, move it once it appears. The fourth is third-party JavaScript, which rarely affects the paint at all and dominates interaction latency.

That ordering is stable across content sites, and it is the reason the sections in this guide are ordered as they are. A team that optimises in this order sees most of the available improvement in the first two changes; a team that starts with bundle splitting on a page that ships 12 KB of its own JavaScript will spend a week for a result nobody can measure.

The corollary is that a static site's performance work is mostly asset work rather than code work. The generator already removed the server, the hydration and the runtime rendering. What remains is bytes, their order, and whether the browser can discover them early — which is exactly what the following sections are about.

Measure in Both Directions, Always

Every section above ends in a number, and those numbers come from two different places that answer different questions.

Lab measurement — Lighthouse, a scripted trace, a build-time budget — is reproducible, runs on every pull request and tells you whether a change helped. Its weakness is that it models one device on one connection loading one page once, which is not what readers do.

Field measurement — the web-vitals library reporting from real page views — tells you what readers actually experienced, across the devices and networks they actually have, over the whole lifetime of the page. Its weakness is latency: you learn about a regression days after it shipped, and only if enough people hit the affected template.

Use the lab to gate changes and the field to choose what to work on. A team that only has lab numbers optimises pages that were already fast for the people visiting them; a team that only has field numbers cannot tell whether last week's change was the cause. The pipeline for collecting the field half is small — a few kilobytes of script and an endpoint — and it is the single highest-leverage addition to a performance practice that already has lab gates in place.

Keep both series per template rather than per URL. A content site has a handful of templates and thousands of pages, and a regression is nearly always a property of the template rather than of any individual page.

Key Takeaways

  • SSGs hand you good LCP and CLS for free; the real work is protecting INP and TTFB.
  • The generator you choose sets the JavaScript baseline before you write any app code — pick for your interactivity needs, not just build speed.
  • Fast incremental builds keep the measure-and-fix loop tight, so build speed protects Core Web Vitals indirectly.
  • Optimize assets at build time — that step sets the ceiling everything else operates under.
  • Use the two-tier cache policy: immutable hashed assets, short-lived HTML.
  • Hydrate only genuine interactive islands, and budget third-party scripts as strictly as your own.
  • Gate every deploy on a performance budget so regressions never reach production silently.

FAQ

How do Core Web Vitals differ for SSGs compared to SPAs?

SSGs serve pre-rendered HTML, so LCP and CLS are strong by default. The remaining work is INP — you still have to control hydration so interactivity matches what a single-page app provides without shipping its full JavaScript cost.

Can static sites achieve top Lighthouse scores in production?

Yes. With disciplined asset optimization, deferred third-party scripts, and edge caching, static sites routinely score 95-100 in the lab. Just remember lab scores can diverge from real-user data, so monitor both Lighthouse and field RUM.

What is the optimal caching strategy for SSG HTML files?

Use a short max-age (0-300s) with stale-while-revalidate for HTML, paired with one-year immutable caching for fingerprinted CSS, JS, and images. This two-tier policy keeps content fresh while making repeat visits nearly free.

How does partial hydration impact SEO and performance?

Search engines receive full crawlable HTML while JavaScript execution is deferred, which improves First Contentful Paint and INP and lowers the main-thread cost. Content is indexable regardless of whether an island has hydrated.

Which Core Web Vital is hardest to fix on a static site?

INP. LCP and CLS are largely solved by pre-rendering and reserved space, but INP depends on how much JavaScript runs on the main thread during interaction — which is entirely under your control through hydration discipline and third-party script budgets.

Do slow builds affect Core Web Vitals?

Not directly, but they change your workflow. When a full rebuild takes ten minutes, teams stop previewing changes and skip the performance checks that catch regressions. Fast incremental builds keep the measure-and-fix loop tight, so build speed protects Core Web Vitals indirectly.