Incremental static builds in Astro 7.2:… | LaunchFast
LaunchFast LogoLaunchFast
Blog
1,129 words6 min read

Incremental static builds in Astro 7.2: cutting my rebuild time by 39%

How Astro 7.2 incremental static builds cut a one-post rebuild from ~16.5s to ~10s, restoring 637 of 661 pages from cache with the right cacheKey setup.

Rishi Raj Jain
Rishi Raj JainAuthor

I cut my incremental rebuild time by about 39%, from about 16.5 seconds to about 10, by enabling incremental static builds in Astro 7.2.

This site prerenders 661 pages on every production build: 70 blog posts in three languages, documentation, feature pages, and use-case pages. Most of those pages do not change between deploys. Before this change, editing a single post still re-rendered all 661.

Astro 7.2 lets a prerendered route return a cacheKey from getStaticPaths. Astro restores the page from the previous build when its module graph and that key are both unchanged. After editing a single post, 637 of 661 pages restore from cache. This post covers how I set that up.

Prerequisites

  • Astro 7.2 or later
  • Prerendered routes (export const prerender = true)
  • Bun for the Starlight patch steps below (npm/yarn/pnpm have equivalent patch tools)

Step 1: Enable the flag

To be able to use the incremental builds, the first step is to upgrade to 7.2 with the following command:

Terminal window
bun add astro@latest

Note that the mode is experimental. Turn it on in astro.config.mjs with the following change:

export default defineConfig({
experimental: {
incrementalBuild: true,
},
})

After enabling the flag, the first two builds took the same amount of time. No pages were restored from cache. Enter concurrency!

Step 2: Fix the concurrency setting

Astro disables the incremental cache when pages render in parallel:

node_modules/astro/dist/core/build/generate.js
if (options.settings.config.experimental.incrementalBuild) {
if (options.settings.config.build.concurrency > 1) {
logger.warn(
'build',
'The incremental build cache is disabled because `build.concurrency` is greater than 1.',
)
} else {
// load the cache and restore pages
}
}

I had build.concurrency set to the CPU count. Parallel rendering and cache restore do not work together, so I removed my override:

astro.config.mjs
// build: {
// concurrency: Math.min(os.cpus().length, 12), // removed
// }

With concurrency back at the default of 1, you lose parallel rendering but gain cache restore. Note that this only helps if most of your site is unchanged between builds.

Step 3: Add a cache key to the blog

A route joins the cache by returning a cacheKey from getStaticPaths. Astro reuses a page only when its module graph and this key both match the previous build.

Content collection entries have a digest that changes when the entry source changes:

export async function getStaticPaths() {
const posts = await getCollection('blog')
return posts.map((post) => ({
params: { slug: post.data.slug },
props: { post },
cacheKey: post.digest,
}))
}

post.digest alone is not enough for my blog template.

Step 4: Include everything the page renders

A blog page here renders:

  • Three related posts (title and description previews)
  • The current sponsor ad in the sidebar

Neither is part of post.digest. With only the post digest as the key, the cache serves a stale page when a related post changes or the sponsor campaign changes.

src/lib/cache-key.ts
import { getActiveSponsor } from '@/lib/sponsor'
type WithDigest = { digest?: string | number }
export function buildContentCacheKey(post: WithDigest, related: WithDigest[] = []): string {
const sponsor = getActiveSponsor()?.id ?? 'none'
const relatedDigests = related.map((entry) => entry.digest ?? '').join('.')
return [post.digest ?? '', relatedDigests, sponsor].map(String).join('|')
}

The blog in all three languages and the feature pages use this key. The cache busts when the post body, a related preview, or the active sponsor changes.

Step 5: Use-case pages

The use-case pages are the largest group on the site: over 300 across languages. They are not content collection entries. They are generated from TypeScript, one page per framework and integration.

The data is imported TypeScript, so it is already in the route module graph. Astro’s dependency hash invalidates every use-case page when the data changes, with or without a cacheKey. The module graph does not cover the sponsor ad, so the key only needs the slug and the active sponsor:

export function buildUseCaseCacheKey(useCase: { slug: string }, related: { slug: string }[] = []): string {
const sponsor = getActiveSponsor()?.id ?? 'none'
const relatedSlugs = related.map((entry) => entry.slug).join('.')
return [useCase.slug, relatedSlugs, sponsor].join('|')
}

Step 6: Docs pages (Starlight patch)

Documentation runs on Starlight, which injects a catch-all [...slug] route. There is no getStaticPaths in my code to add a key to.

I first tried shadowing the route with src/pages/[...slug].astro. Docs restored from cache, but Astro logged a route conflict for every docs path. Two routes claimed the same pattern.

The fix is to patch Starlight’s injected route and track it with bun patch:

Terminal window
bun patch @astrojs/starlight
# edit node_modules/@astrojs/starlight/routes/static/index.astro
bun patch --commit 'node_modules/@astrojs/starlight'

This writes a patch under patches/ and a patchedDependencies entry in package.json. Bun reapplies it on install:

---
// node_modules/@astrojs/starlight/routes/static/index.astro (patched)
import { paths } from '../../utils/routing';
import CommonPage from '../common.astro';
import { createHash } from 'node:crypto';
export const prerender = true;
export async function getStaticPaths() {
const docsSetHash = createHash('sha1')
.update(paths.map((p) => `${p.props?.entry?.id ?? ''}:${p.props?.entry?.digest ?? ''}`).sort().join('|'))
.digest('hex');
return paths.map((p) => ({ ...p, cacheKey: `${p.props?.entry?.digest ?? ''}|${docsSetHash}` }));
}
---
<CommonPage />

Every docs page renders a shared sidebar and previous/next links from the whole docs set. The key uses two parts:

  1. The entry digest (this page’s content)
  2. A hash over every doc (sidebar and pagination)

If the docs did not change, all 96 docs pages restore from cache.

Results

A full cold build against an incremental rebuild after editing a single post, at concurrency 1:

Build Pages restored Wall time
Cold (cache cleared) 0 / 661 ~16.5s
Incremental (one post edited) 637 / 661 ~10s

About 39% faster once the cache is warm. Editing one post re-renders 24 pages, that post plus the always-dynamic index pages, and restores the other 637 🤯

Continue reading