Fixing Stale Pages From Incremental Builds

Incremental builds make large static sites practical: change one page, rebuild a handful of files in seconds instead of thousands in minutes. They work by keeping a dependency graph — which output files depend on which inputs — and rebuilding only what an input change affects. When the graph is complete, the result is identical to a full build. When it is not, the site quietly serves stale pages: a navigation menu that still lists a renamed page, a "related posts" block that misses yesterday's post, a price that changed in a data file but not on the product pages.

This guide finds and fixes stale pages on a 7,500-page Eleventy site built incrementally in CI, where readers reported outdated navigation and a footer that showed last month's legal entity name. It shows how to detect stale pages reliably, the four causes found, and how to keep incremental builds honest. It belongs to Incremental Builds and Build Caching for SSGs; the incremental setup itself is in Enabling Incremental Builds in Eleventy.

Step 1: Detect Stale Pages by Comparison

The only reliable test is to build the same commit both ways and diff the outputs:

# incremental: restore the previous build's cache and output, then build
restore_cache && npx @11ty/eleventy --incremental --output=_site-inc
# full: from scratch
rm -rf .cache _site-full && npx @11ty/eleventy --output=_site-full
diff -rq _site-inc _site-full | sed 's/^Files \(.*\) and .* differ$/STALE \1/' | sort | head -50

Run after a sequence of real commits — the comparison only reveals a gap once an untracked input has changed. Replaying the last 30 commits of the repository one at a time, building incrementally after each and comparing with a full build at the end, found 2,940 stale files.

Number of stale output files by cause after replaying 30 commits Horizontal bars. Global data file changes not propagated to pages 1,910 files. Pages listing other pages through collections 640. Includes referenced by a computed name 270. Orphaned outputs from deleted source files 120. Stale files by cause Global data not tracked 1910 Collection listings 640 Dynamic include names 270 Orphaned outputs 120
Found by diffing incremental and full builds of the same commit after replaying 30 commits.

Cause 1: Global Data Used Everywhere

The footer read site.legalEntity from _data/site.json. Changing that file should rebuild every page — but the incremental build treated global data as a dependency of templates that referenced it directly, and the footer was an include rendered inside the layout through a shortcode that read the data from a JavaScript module, not from the cascade. The dependency was invisible. Eleventy's addWatchTarget and dependency declarations exist for this case; the simplest fix was to pass the data through the cascade so Eleventy sees it:

{# footer.njk — reads from the data cascade instead of importing the JSON in JS #}
<p>{{ site.legalEntity }} · {{ site.address }}</p>

For data that genuinely must be read in JavaScript, declaring the file as a dependency of all templates (eleventyConfig.addWatchTarget('./_data/site.json') plus a full rebuild when it changes) achieves the same. On this site, changes to files in _data/ now trigger a full build by rule — they are rare, and a full build is 2 minutes.

Cause 2: Pages That List Other Pages

Section index pages, tag pages, "related posts" and the navigation sidebar all list other pages. When a post's title changed, the post itself rebuilt, but the 14 pages listing it did not, because their own inputs had not changed. This is the classic incremental build problem, and generators handle it differently: Eleventy's incremental mode rebuilds templates that use a collection when any collection member changes only for certain operations. The fix chosen here was explicit: templates that render collections declare it in front matter, and the build script maps changed files to the collections they belong to:

// scripts/affected.mjs — given changed files, list templates that must rebuild
const changed = getChangedFiles();               // from git diff against the last built commit
const tags = new Set(changed.flatMap((f) => frontMatter(f).tags ?? []));
const listing = templates.filter((t) => (t.data.rendersCollections ?? []).some((c) => tags.has(c) || c === 'all'));
console.log([...changed, ...listing.map((t) => t.inputPath)].join('\n'));

The build runs Eleventy with those inputs. A coarser alternative — always rebuilding all index and tag pages, about 400 on this site, on every build — adds 9 seconds and is what the team settled on for simplicity.

Dependency chain from a changed post to the pages that list it Four steps. A post's front matter title changes. The post page itself rebuilds. Pages listing it through its tags and section, such as the section index, tag pages and related-post blocks, must rebuild too. The sidebar navigation on every page in the section must rebuild if it shows titles. What must rebuild when a post's title changes Post title changes front matter Post page rebuilt Listing pages index, tags, related Section sidebar on every section page
The last two steps were missing from the dependency graph, which caused 640 stale files.

Cause 3: Includes Chosen by Name at Runtime

Product pages included a callout with {% include "callouts/" + product.callout + ".njk" %}. The template engine resolved the include at render time, so the dependency graph never learned which callout files a page used. Editing a callout left its pages stale. Replacing the dynamic include with an explicit map of known callouts — imported at the top of the template — made the dependencies static. Where dynamic includes cannot be avoided, treating the whole folder as a dependency of the template that uses it is the safe approximation.

Cause 4: Orphaned Outputs From Deleted Files

The incremental build reused the previous _site directory. When a source file was deleted or renamed, its old output stayed and was deployed, so 120 removed pages were still live, including two the legal team had asked to remove. The build now writes to a fresh output directory and copies unchanged files from the previous build by manifest — or, more simply, the deploy step uploads only files listed in the new build's manifest and deletes everything else at the host. Deploying Only Changed Files covers the manifest approach.

Step 2: Keep a Safety Net

Two practices keep incremental builds honest after the fixes:

  1. A nightly full build that diffs against the deployed incremental output. Any difference is reported with the file list, and the full build's output is deployed. A gap in the dependency graph is then never stale for more than a day, and the report says exactly where it is.
  2. A rule for full builds. Changes to layouts, _data/, the config file or package versions trigger a full build automatically, detected by git diff --name-only against the last built commit.
Build time and stale files before and after the fixes Paired bars. Typical incremental build time went from 22 seconds to 31 seconds after adding listing pages to every build. Full build time stayed at 125 seconds. Stale files after 30 replayed commits went from 2,940 to 0. Build time and stale files Before fixes After fixes Incremental build (s) 22 31 Full build (s) 125 125 Stale files 2,940 0
Build times median of ten content-only commits; stale files from the replay comparison.

Measured Impact

MeasureBeforeAfterTool
Stale files after 30 replayed commits2,9400Incremental vs full diff
Orphaned pages live1200Manifest diff
Incremental build, content change22 s31 sCI timing
Full build2 min 05 s2 min 05 sCI timing
Nightly diff findings, 8 weeks—1 (a new dynamic include)Nightly report

Incremental builds got 9 seconds slower and remained four times faster than full builds.

Pitfalls & Rollback

  • Trusting incremental output without comparing it to a full build. Replay commits and diff.
  • Data read outside the cascade. The dependency graph cannot see it.
  • Listing pages not rebuilt. Rebuild them on every build, or map changes to them explicitly.
  • Reusing the output directory. Deleted pages stay live.
  • Rollback: the full build is always available; switching CI to full builds only restores correctness at the cost of time.

Conclusion

Stale pages from incremental builds come from inputs missing in the dependency graph: data read outside the generator's knowledge, pages that list other pages, includes chosen at runtime, and outputs left behind by deleted files. Comparing incremental and full builds of the same commit finds them; declaring dependencies, rebuilding listings every time, building into a clean output and running a nightly full-build diff keeps incremental builds both fast and correct.

FAQ

Why does an incremental build leave pages stale?

Because the generator decides what to rebuild from a dependency graph, and something a page depends on is missing from that graph. Common examples are global data files, layouts and includes used through dynamic names, collections listing other pages, and content fetched at build time. When the untracked input changes, the dependent pages are not rebuilt.

How can I tell which pages are stale?

Build the same commit twice, once incrementally from the previous build's cache and once from scratch, and compare the outputs. Every file that differs is stale in the incremental build. Running this comparison in CI on a schedule catches dependency gaps soon after they are introduced.

Do deleted pages cause problems in incremental builds?

Yes. If the previous output directory is reused, the HTML of a deleted source file can remain in the output and keep being deployed. Some generators clean up deleted outputs, others do not. Building into a clean directory, or deploying only files listed in the new build manifest, prevents orphaned pages.

Should I give up on incremental builds if they cause stale pages?

Usually not. Most stale-page bugs come from a few untracked dependencies that can be declared explicitly, and a scheduled full build plus an output comparison catches the rest. On the site in this guide, incremental builds stayed about five times faster than full builds after the fixes.

Filed underFixes