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.

Full build versus cached incremental build Two pipelines compared. The top path is a cold full build that installs dependencies, processes all assets, and renders every page in 240 seconds. The bottom path restores a cache, processes only changed assets, and renders only affected pages in 28 seconds. Cold full build vs. warm cached build Full build · cold runner npm ci ~70s process all assets ~95s render all pages ~75s 240s total every push Warm build · restored cache restore cache ~6s changed assets only ~8s affected pages ~14s 28s total typical push
A cold runner redoes install, asset processing, and rendering on every push; restoring caches and doing only the changed work collapses a 240s build to roughly 28s.

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 --incremental flag 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 server and watch mode, and — crucially for CI — caches processed images, Sass, and other resources under resources/_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 under node_modules/.astro, so restoring that directory skips re-encoding images on a fresh runner.
  • Next.js static export keeps a .next/cache that the build reuses for compiled modules and (when applicable) data; the Next docs explicitly recommend caching that directory in CI.
  • Gatsby maintains a .cache and public pair that powers its incremental builds; restoring both lets it skip unchanged queries and pages.
GeneratorSingle-run incrementalWhat to cache between CI runs
Eleventy--incremental flagnode_modules, any .cache you configure
Hugochanged pages in watch moderesources/_gen, module cache ($GOPATH or --cacheDir)
Astrofull rebuild per runnode_modules/.astro, node_modules
Next.js exportpartial via .next/cache.next/cache, node_modules
Gatsbyyes, 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 strategyHit behaviorRisk
Hash of lockfileHits until deps changeNone — exactly right for deps
Hash of content dirHits until content changesOver-broad if one edit busts the whole cache
Commit SHA in keyNever hitsCache is created and never restored
Fixed string (no hash)Always hits, never refreshesStale 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_modules but the generator writes its artifacts to resources/_gen or .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-keys for 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/_gen for Hugo, node_modules/.astro for Astro, .next/cache for Next.js export.
  • Key caches on a hash of their inputs (lockfile for deps, content for assets) and add restore-keys so 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.

Static Site Generators in Production