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. The numbers below come from a 900-page documentation site with roughly 400 source images, built on a standard 2-core GitHub-hosted runner; treat them as ratios, not guarantees.

  • Eleventy has a first-class --incremental flag that rebuilds only the templates affected by the file you just changed. In watch mode a single-file edit that took 9.4s to rebuild the whole site drops to about 0.3s incremental. 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. On the test site, restoring resources/_gen cut the image-processing phase from 41s cold to under 2s warm, taking the whole build from 58s to 14s. 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. Re-encoding 400 images with Sharp accounted for ~85s of a 150s cold build; a warm node_modules/.astro brought that phase to near zero and the total to about 65s.
  • 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. Restoring .next/cache typically shaves 30-40% off next build by skipping already-compiled webpack modules.
  • Gatsby maintains a .cache and public pair that powers its incremental builds; restoring both lets it skip unchanged queries and pages, turning a full GraphQL re-query into a delta and taking a 2m20s cold build to roughly 40s warm.
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. In practice this two-tier lookup is why a dependency bump costs ~8s of delta fetch instead of a 70s cold npm ci.

How a two-tier cache lookup resolves A decision flow for restoring a CI cache. The runner first tests the exact primary key: on a hit it does a full restore and reuses everything in about six seconds. On a miss it tests the restore-keys prefix: a prefix match gives a partial restore that fetches only the deltas in about eight seconds. If neither matches, the job falls through to a cold build that runs a full npm ci in about seventy seconds. How a two-tier cache lookup resolves Job needs npm ci Exact key match? restore-keys prefix match? Full restore — reuse everything ~6s · fast path Partial restore, fetch deltas ~8s · warm start Cold — full npm ci ~70s · cold start yes yes no no
The primary key decides whether you skip work at all; restore-keys decides how cheap the fallback is. Only when both miss do you pay for a cold build.

The key insight is that primary key and restore-keys play different roles: the primary key decides whether you skip work, while restore-keys decides how cheap the fallback is when you cannot. Drop the fallback and every miss is a cold start; drop a content hash from the primary key and you serve stale artifacts. You want both.

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.

Verifying the Cache Actually Helps

A cache you never measure is a cache you cannot trust. The single most useful signal is already in the runner log: actions/cache prints either Cache restored from key: ... or Cache not found for input keys: ... at the start of the job, and Cache saved with key: ... at the end. If you see a save on every single run but never a restore, your key is changing every time — the classic SHA-in-the-key mistake.

Beyond the log line, wrap the build step in a timer and compare a cold run against a warm one on the same commit. Re-run the job twice: the first populates the cache, the second should restore it. If the second is not meaningfully faster, the tool is not reading the directory you cached.

- name: Build (timed)
  run: |
    start=$(date +%s)
    npm run build
    echo "build_seconds=$(( $(date +%s) - start ))" >> "$GITHUB_STEP_SUMMARY"

On the 900-page test site the two runs told the whole story at a glance: 152s cold, 61s warm — a 60% reduction that traced entirely to the restored asset cache. If your warm number barely moves, the fix is almost always a path mismatch, not a bigger cache. One caveat worth logging deliberately: a warm build hides broken cold-build steps, so keep an occasional scheduled run with the cache disabled to catch a dependency that only resolves because it was already on disk.

SignalHealthyBroken
Log at job startCache restored from keyCache not found every run
Log at job endCache saved only when key is newCache saved on every run
Warm vs. cold build timeWarm is 40-80% fasterNo meaningful difference
Cache size trendStable, near the artifact setGrows unbounded run over run

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.
What each generator can actually skip Three panels: Hugo skips image processing and cached partials, Eleventy skips unaffected templates in watch mode, and Astro and Next skip asset transforms through their own caches. Each names what is never skipped. What each generator can actually skip Hugo image processing via cacheDir partials via partialCached never: the full render pass full builds are fast enough anyway Eleventy unaffected templates in watch passthrough copy of unchanged files never: listing pages that use collections CI always starts cold Astro / Next asset and image transforms module graph via the bundler cache never: the page render itself the cache is where the time is CI benefits almost exclusively from the second — a clean runner has no previous run to be incremental against.
Two different mechanisms hide under one word: skipping work within a run, and reusing work between runs.

That distinction is worth being pedantic about, because it decides where effort goes. Incremental builds improve the edit loop and do nothing for a CI runner that starts from a fresh checkout. Caching improves CI and does nothing for a developer whose cache is already warm. A team that adopts one expecting the other's benefit concludes, reasonably, that the feature does not work.

Measure the cache rather than trusting it. A cache that silently stops hitting looks exactly like a build that got slower, and the two have completely different fixes. Log the restore status and the number of items reused on every run; a hit rate that drops from 95% to 20% after a dependency bump is obvious in a log and invisible in a build-time graph.

Finally, be honest about when none of this is worth doing. A site that builds in eight seconds does not need a cache, and adding one costs a configuration surface, a class of stale-restore bug, and a key that has to be maintained. The threshold where caching starts paying for itself is roughly the point where the build is long enough that people notice waiting for it — around a minute in CI, or a couple of seconds in the edit loop. Below that, the correct optimisation is to leave it alone.

The corollary is that build-time work is worth measuring before it is worth optimising, which is why the first recommendation in this guide is a number rather than a technique.

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.