Say your site reads posts from a database and bakes them into static pages. You unpublish a post in the admin screen, deploy, and find the post is public again. Or the opposite: your store changes a few hundred products a day, yet tens of thousands of product pages are rebuilt every day and the database bill never drops. The two symptoms look like opposites. They have the same cause.
What does a cache look at when it reuses or clears something?
A cache only knows the inputs written into its keys and tags — when those drift from what the result really depends on, it either clears too little and serves stale output, or clears too much and leaks cost. A build cache trusts "same inputs, same output." A page cache trusts "if this tag is invalidated, this page is stale." Both are only as accurate as the keys and tags a person wrote. The cache has no way to know what changed outside its key.
So there is one thing to do before adding a cache: write down what would make this result different, and line up the keys, tags and invalidation order with that list.

Why does a narrow key bring stale output back?
If an input outside the key changes, the cache still sees "the same inputs." Two common cases:
- Build caches and databases. Monorepo build tools such as Turborepo restore build output from a hash of source files, config and declared environment variables (Turborepo caching docs). If your app reads the database during the build to bake pages, the database contents are not in that hash. A deploy with no code change ships pages baked from old data. Unpublished posts return; test data reaches production.
- Deploy deduplication. Git-connected deploys often key on the commit. Pushing the same commit to another branch, or force-pushing it, may not create a new deploy if that commit was already seen. If nothing even says "skipped," the integration looks broken. To rebuild, change the key — add a new commit.
The fix is one of two: put the data into the key (pass a data version or last-modified time as a build environment variable so it enters the hash), or take that task out of the cache. If a data version is hard to get into the key, removing the cache is the safer choice.
Why does a wide tag wipe pages that were fine?
Tag-based invalidation works as "every page that uses a cached piece with this tag is now stale" (Next.js revalidateTag). Suppose each product page also renders a small "category summary" piece tagged with the category. Change one product, and every product page in that category is cleared with it.
Cleared pages are rebuilt one by one by the next visitor. If that visitor is a search crawler, your database stays awake all day. The fix is to store the parent-level data inside a detail page under the detail page's own tag, or to split it off by a time bucket (for example, weekly). Tag a page by "what change would make this page wrong," not by "what does this page touch."
When does an invalidation actually take effect?
Assume invalidation is instant and the order breaks. Some frameworks apply an invalidation called inside a request only after that request has returned its response. If you "invalidate, then immediately rebuild" inside one request, the rebuild reads the old cache, and the cache empties after the job is done. The result: "we rebuilt, yet the pages are cold."
- Send the invalidation as its own request first
- Confirm it took effect on one sample URL
- Then rebuild
What to write down before adding a cache
Paste this next to the cache config or into the design doc.
Result this cache restores: ______
Every input that changes this result:
- code / config / environment variables
- database contents (which table? which rows?)
- clock (today's date, year labels)
- external API responses
Inputs in the key: ______
Inputs left outside the key: ______ -> add to key / remove cache / write why it is safe
Invalidation unit (tag): ______ -> pages cleared together when this tag clears: ______
When invalidation takes effect: ______ -> rebuild only after that
Checklist order:
- List every input the result depends on — look for the ones outside code first (data, clock, external responses)
- Compare with what is in the key and decide a fix for each input left outside
- Count how many pages one tag clears — compare with how many actually changed
- After invalidating, confirm on a sample URL, then rebuild
- Do one deploy that changes only data, not code, and check the output shows the new data
Four cases we ran into
- A build cache revived old data. On a site that bakes posts from a database, there was 1 real post, but a build from when there were 46 came back from cache. A post taken down in the admin screen could have gone public again on the next deploy. Taking that app's build out of the cache fixed it.
- A wide tag cleared 84% every day. On a local information site, about 1,900 items changed per day, but detail pages embedded a piece tagged with the parent region, so about 15,000 pages (84% of the total) were cleared daily. After a nightly job rebuilt only what changed, crawlers rebuilt the rest all day and kept the database awake.
- An in-request invalidation landed after the rebuild. On the same site the database was awake 19 hours a day. Invalidation and rebuild were called back to back in one route; invalidation applied after the response, so the rebuild only got the old cache.
- The same commit was not deployed again. To bring back a preview deploy, we force-pushed the same commit and pushed it to a new branch. No deploy appeared and no "skipped" entry either. Adding a new commit built it right away.
None of the four raised an error. Each cache did exactly what its key said — the key was what was wrong.
PreviousReacting to instant readings amplifies noise — decide what axis to pick the value along before you react