Incremental Builds and Build Caching for SSGs
A static site generator's whole value proposition is that the expensive work happens once, at build time. That promise breaks down when "once" becomes "every push, from scratch, for ten minutes." As content grows past a few hundred pages — or you add image processing, syntax highlighting, and search-index generation — the build becomes the slowest part of your deploy pipeline. The fix is two related techniques: incremental builds, which rebuild only what changed within a run, and build caching, which carries artifacts from one run to the next so a clean CI machine never redoes settled work. This guide covers both across the major generators, as part of Production-Ready Deployment & CI/CD Workflows.
The two ideas are easy to confuse. Incremental builds are about a single invocation: given a change to one Markdown file, only re-render the pages that depend on it. Build caching is about between invocations: a fresh GitHub Actions runner starts with an empty disk, so restoring node_modules, a processed-image cache, or Hugo's resource cache from the previous run is what saves the time. In CI you almost always want caching; incremental flags help most in local watch mode and on hosts that persist a working directory.
What Each Generator Supports
The generators differ sharply in how much they reuse. Knowing exactly which directory each one writes to is the difference between a cache that helps and one that silently does nothing.
- Eleventy has a first-class
--incrementalflag that rebuilds only the templates affected by the file you just changed. It is the strongest single-run incremental story of the bunch — see Enabling Incremental Builds in Eleventy for the watch-mode and programmatic details. - Hugo rebuilds only changed pages in
hugo serverand watch mode, and — crucially for CI — caches processed images, Sass, and other resources underresources/_gen, plus downloaded modules in its module cache. Restoring those between runs is the whole game; Caching Hugo Builds in GitHub Actions walks through the cache keys. - Astro rebuilds the full site on each
astro build, but persists processed assets undernode_modules/.astro, so restoring that directory skips re-encoding images on a fresh runner. - Next.js static export keeps a
.next/cachethat the build reuses for compiled modules and (when applicable) data; the Next docs explicitly recommend caching that directory in CI. - Gatsby maintains a
.cacheandpublicpair that powers its incremental builds; restoring both lets it skip unchanged queries and pages.
| Generator | Single-run incremental | What to cache between CI runs |
|---|---|---|
| Eleventy | --incremental flag | node_modules, any .cache you configure |
| Hugo | changed pages in watch mode | resources/_gen, module cache ($GOPATH or --cacheDir) |
| Astro | full rebuild per run | node_modules/.astro, node_modules |
| Next.js export | partial via .next/cache | .next/cache, node_modules |
| Gatsby | yes, via cache | .cache, public, node_modules |
Designing Cache Keys That Actually Hit
A cache is only useful if it restores on the runs you want and rebuilds on the ones you need. The discipline is identical to the asset caching in Image Optimization Pipelines in Astro: key the cache on a hash of the inputs that determine its contents.
For dependencies, key on the lockfile so the cache is reused until you change a package:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
The restore-keys prefix is what keeps a near-miss warm: when the lockfile changes, the exact key misses, but the most recent npm-Linux-* entry is restored as a starting point, so npm ci only fetches the deltas. For an asset cache, key on the content that feeds it — hashFiles('assets/**') for an image cache — so an unchanged media directory always hits.
| Cache key strategy | Hit behavior | Risk |
|---|---|---|
| Hash of lockfile | Hits until deps change | None — exactly right for deps |
| Hash of content dir | Hits until content changes | Over-broad if one edit busts the whole cache |
| Commit SHA in key | Never hits | Cache is created and never restored |
| Fixed string (no hash) | Always hits, never refreshes | Stale artifacts silently reused |
The two failure modes at the bottom of that table are the ones people actually ship. A commit SHA in the key means the key is unique every run, so you write a cache nobody ever reads. A fixed string means you never pick up new dependencies. Both look like the cache "isn't working"; the runner log tells you which by reporting whether the entry was restored or created.
Content-Hash Invalidation
The most robust caches invalidate on content rather than time. Instead of expiring a cache after N hours, derive the key from a hash of the files that produced it, so the cache is correct by construction: if the inputs are byte-for-byte identical, the cached output is reused; if anything changed, the key changes and the work reruns.
This is why fingerprinted asset filenames and content-hashed cache keys reinforce each other. Hugo's resources/_gen already names processed files by a hash of their source plus transform options, so caching that directory is safe — a stale entry simply never matches a new request. You can apply the same idea to your own generated artifacts:
# Bust a search-index cache only when content or the indexer config changes
INDEX_KEY="searchidx-$(cat content/**/*.md scripts/build-index.mjs | sha256sum | cut -c1-16)"
Because the key is a function of the exact bytes that go into the index, you never serve a stale index and you never rebuild an unchanged one. The same reasoning underpins safe edge caching — see Setting Up Proper Cache Headers on Netlify, where hashed filenames are exactly what make a one-year immutable cache safe.
Remote and Shared Caches
The per-repository cache that GitHub Actions provides is scoped to one repo and, by default, isolated per branch. That is fine for a single linear pipeline, but it leaves performance on the table when you run several jobs in parallel — a matrix build, or a monorepo where many packages build at once. A remote cache lets one runner populate a shared store and every other runner read from it.
The common implementations are Turborepo's remote cache (self-hostable or hosted), an S3 bucket fronted by a small action, or carefully scoped actions/cache entries with shared restore-keys. The payoff scales with parallelism: when ten matrix jobs would each spend 90s processing the same assets, having the first job publish the result and the other nine restore it turns 900s of redundant work into roughly 90s plus nine fast restores. Sharing Build Cache Across CI Runners covers the architecture, signing, and the measured savings in detail.
This is also the bridge to a clean CI/CD pipeline overall — caching is one of the levers covered in GitHub Actions for Automated SSG Builds, alongside triggers, concurrency, and atomic deploys.
Common Pitfalls
- Caching the wrong directory. If you cache
node_modulesbut the generator writes its artifacts toresources/_genor.next/cache, the build still runs cold. Confirm the path against the tool's docs and the runner log. - Keys that never hit. A commit SHA or timestamp in the cache key makes every key unique, so you write caches nobody reads. Key on content hashes instead.
- Keys that never refresh. A fixed string key always hits and never picks up new dependencies or content. Pair a hashed primary key with
restore-keysfor the fallback. - Stale incremental state. Eleventy and Gatsby keep on-disk state that can desync after a crashed build. When output looks wrong, delete the cache directory and do one clean build to reset it.
- Ignoring cache size limits. GitHub Actions evicts caches past a 10 GB per-repo budget on a least-recently-used basis. A bloated cache that includes
dist/can evict the deps cache you actually wanted.
Key Takeaways
- Incremental builds skip unchanged work within a run; caching carries artifacts between runs. CI needs caching; watch mode benefits from incremental flags.
- Cache the exact directory the generator reads —
resources/_genfor Hugo,node_modules/.astrofor Astro,.next/cachefor Next.js export. - Key caches on a hash of their inputs (lockfile for deps, content for assets) and add
restore-keysso near-misses stay warm. - Reach for a remote or shared cache when parallel jobs would otherwise each redo the same work — measure the redundant seconds first.
FAQ
What is the difference between an incremental build and build caching?
An incremental build rebuilds only the pages affected by a change within a single run, usually in local watch mode. Build caching restores artifacts from a previous run — node_modules, processed images, Hugo resources — so a fresh CI machine skips work it already did. They are complementary: caching warms the inputs, incremental logic skips the unchanged outputs.
Which static site generators support true incremental builds?
Eleventy has an explicit --incremental flag, Hugo rebuilds only changed pages in server and watch mode and caches processed resources, Gatsby supports incremental builds through its cache, and Next.js reuses its .next cache across runs. Astro rebuilds the whole site per run but caches processed assets under node_modules/.astro. Full from-scratch CI builds rarely benefit from incremental flags; that is where caching matters.
What should a CI cache key be based on?
Hash the inputs that determine the output. Use a lockfile hash for dependency caches and a content or config hash for asset caches. A key like deps-os-hash-of-lockfile restores only when the lockfile is unchanged, and a restore-keys prefix lets a near-miss fall back to the most recent compatible cache instead of starting cold.
Why did my cache restore but the build still ran from scratch?
The cache path probably did not match what the generator actually reads, or the cache key changed on every run because it included a timestamp or commit SHA. Cache the exact directory the tool writes to, key it on stable content hashes, and confirm with the runner log that the entry was restored rather than created.
When is a remote or shared cache worth the complexity?
When several parallel jobs or many short-lived runners would otherwise each rebuild the same artifacts. A remote cache backed by S3 or a provider like Turborepo lets one runner populate the cache and every other runner read it, which pays off for monorepos and matrix builds. For a single small site, the built-in per-repo cache is enough.
Related
- Parent: Production-Ready Deployment & CI/CD Workflows — where build speed fits the deploy pipeline.
- Enabling Incremental Builds in Eleventy — the
--incrementalflag and what gets rebuilt. - Caching Hugo Builds in GitHub Actions — caching
resources/_genand the module cache. - Sharing Build Cache Across CI Runners — remote caches for parallel runners.
- GitHub Actions for Automated SSG Builds — the surrounding build-and-deploy pipeline.