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.
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.
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:
- 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.
- A rule for full builds. Changes to layouts,
_data/, the config file or package versions trigger a full build automatically, detected bygit diff --name-onlyagainst the last built commit.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Stale files after 30 replayed commits | 2,940 | 0 | Incremental vs full diff |
| Orphaned pages live | 120 | 0 | Manifest diff |
| Incremental build, content change | 22 s | 31 s | CI timing |
| Full build | 2 min 05 s | 2 min 05 s | CI 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.
Related
- Parent: Incremental Builds and Build Caching for SSGs — making builds faster safely.
- Enabling Incremental Builds in Eleventy — the setup this fixes.
- Incremental Rebuild Times Across Astro, Eleventy and Hugo — how much incremental builds save per generator.
- Deploying Only Changed Files — manifests and orphaned files.
- Snapshot Testing Build Output — comparing outputs as a test.
Filed underFixes