---
title: "Caching audits: Five antipatterns that quietly cost performance and money"
description: "Five caching antipatterns from hundreds of Vercel technical audits: write amplification, deploy-wiped caches, spinner shells, and how to diagnose each."
url: /kb/guide/caching-antipatterns
canonical_url: "https://vercel.com/kb/guide/caching-antipatterns"
published: 2026-09-04
last_updated: 2026-09-04
authors: Forward Deployed Engineering Team
related:
  - /docs/caching/runtime-cache
  - /docs/cli
  - /docs/caching
  - /docs/incremental-static-regeneration
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Our Forward Deployed Engineering team has delivered hundreds of audits across code review, web performance, and platform usage. One theme recurs more than any other: the cache teams think they have is not the cache they actually have.

Metrics show healthy hit ratios while users stare at spinners, cache writes outnumber reads, and a deploy schedule silently caps how warm the cache can get. None of this surfaces as an error, only as slower pages and a larger usage bill.

These are five of the most critical caching issues we find in customer audits, along with the diagnostics we use to catch them.

## Key takeaways

- **Compare ISR writes to reads per route.** Ratios near or above 1:1 on content routes mean you're paying to regenerate pages nobody reads.
  
- **Your deploy schedule is a cache policy.** Every production deployment wipes the ISR cache, so a high deploy cadence can cap your hit ratio more than any configuration change. Use the [Runtime Cache](https://vercel.com/docs/caching/runtime-cache) to persist data fetches.
  
- **Don't treat a cache HIT as proof of a fast page.** Read the cached HTML. If your hero and primary content aren't in it, the cache is serving a spinner.
  
- **Only inputs that change the rendered document belong in the cache key** or in a bypass condition. Consent cookies, analytics cookies, and UI preferences move below the cache boundary.
  
- **Give the cache one owner.** A data-access layer with semantic, entity-specific tags, where every write pairs with its invalidation, prevents the other four antipatterns from creeping back in.
  
- **Check the low-effort fixes first**: dynamic 404 pages, static files served through compute, and unprotected cache-purge endpoints appear in almost every audit.
  

## 1\. Write amplification: when the cache costs more than it saves

Caching is usually framed as a performance win. In our platform usage audits, it also shows up as a cost problem.

The clearest case was an audit where ISR cache writes exceeded reads on every busy content route. System-wide, that meant roughly 65 million writes against 48 million reads over two weeks, with the product route regenerating twice for every read it served. The serving side looked great, at about 90% combined CDN + ISR hit rate. The invalidation side was the problem:

- Recursive fan-out in the CMS revalidation webhook
  
- Bare content-type tags that purged everything of a kind
  
- Product-feed purges that invalidated even PDP shells whose volatile data (stock status and price) wasn't in the static shell to begin with
  

Because the routes defined no time-based revalidation, write volume was a direct readout of purge breadth, not of traffic to expired pages.

The same audit surfaced a sneakier variant: a `force-static`, 404-only catch-all route. Because the path is the cache key, every bot probe and typo minted a durable ISR entry, leaving thousands of junk paths cached and almost never read.

```tsx
// a 404-only catch-all force-static makes every bogus URL a durable ISR entry
import { notFound } from 'next/navigation';

export const dynamic = 'force-static';

export default function CatchAll() {
  notFound();
}
```
```tsx
// Render it dynamically; there is nothing worth storing
import { notFound } from 'next/navigation';

export const dynamic = 'force-dynamic';

export default function CatchAll() {
  notFound();
}
```

**The diagnostic**: Compare ISR writes to reads per route in the Vercel dashboard. Ratios near or above 1:1 on content routes mean you're paying to regenerate pages nobody reads. Instead of removing caching, restructure the page to cache as much data as you can without constant revalidation, and deliver the rest dynamically. Instrument before you restructure: don't narrow tags or rework invalidation until the data shows it's the driver.

## 2\. Your deploy schedule is your cache policy

Every deployment wipes the ISR cache. Most teams know this abstractly; few connect it to their hit ratio.

One team came to us because their ISR hit ratio roughly halved, from about 24% to 12%, after a framework migration. We found that the team had been deploying many times per day with fast-follow fixes after the go-live. Each deployment wiped the ISR cache, and the hit ratio took a few hours to climb back after every release. During a three-day deploy freeze, the same site's hit rate climbed toward 98%. Nothing about traffic or code changed, only the deploy cadence.

**The diagnostic**: Overlay your deploy timestamps on your cache hit ratio graph to judge the impact of deployments on your hit ratio.

The solutions:

- **Persist data fetches across deployments with the Runtime Cache.** Access the cache [directly](https://vercel.com/docs/caching/runtime-cache#using-runtime-cache) or by [adding](https://vercel.com/docs/caching/runtime-cache#using-fetch-with-force-cache) `force-cache` to your fetch calls. This speeds up static page re-renders after a deployment and dynamic page renders.
  
  ```tsx
  export default async function Page() {
    const res = await fetch('https://api.example.com/blog.json', {
      cache: 'force-cache',
      next: {
        revalidate: 3600,
        tags: ['blog'],
      },
    });
    const data = await res.json();
  
    return (
      <main>
        <Blog content={data} />
      </main>
    );
  }
  ```
  
- **Decouple merges from production deploys.** For teams deploying multiple times a day who also want to squeeze out performance wins, especially in millisecond-critical applications like e-commerce, we often recommend a staging deployment on merges to `main`, then promoting to production manually rather than on every merge. This shields your production cache from frequent invalidations.
  

## 3\. The cache is hitting, but it's serving a spinner

One of the most deceptive findings in our audits: a route served from the Full Route Cache whose cached HTML contains almost nothing.

In a recent audit of a multi-brand commerce monorepo, the homepage was a cache HIT, but the cached HTML held little more than a loader shell. The header, CMS content, hero, and footer were all missing, so hits didn't produce a fast visual load: field p75 LCP on mobile exceeded 4 seconds on a "fully cached" route.

The code reading dynamic data is rarely wrong by itself; the problem is where it's read. Here, search params were read high in the tree, under a route-wide Suspense boundary, so everything below waited on the request and the cached shell was just the fallback: a loader. The rest arrived post-hydration from client-side libraries, invisible to the cache, crawlers, and AI answer engines.

```tsx
// Dynamic data read too early, at the top of the tree.
// The Suspense boundary has to sit above the whole page,
// so the cached shell is just its fallback: a spinner.
'use client';

import { useSearchParams } from 'next/navigation';

export default function HomePage() {
  const params = useSearchParams(); // everything below now waits on the request
  const promo = params.get('promo');

  return (
    <>
      <Header />
      <Hero />
      <PromoBanner promo={promo} /> {/* the only component that needs it */}
      <PageContent />
      <Footer />
    </>
  );
}
```
```tsx
// Move the read down to the component that needs it.
// Header, hero, and footer render into the cached shell;
// only the banner waits on the request.
import { Suspense } from 'react';

export default function HomePage() {
  return (
    <>
      <Header />
      <Hero />
      <Suspense fallback={<BannerSkeleton />}>
        <PromoBanner /> {/* a Client Component that reads useSearchParams() */}
      </Suspense>
      <PageContent />
      <Footer />
    </>
  );
}
```

We've seen the same shape elsewhere:

- Home hero and product-listing personalization resolved client-side, leaving the first server response without its default content
  
- One product detail page lived in a `(cached)` route group but called `await connection()` with no `use cache`, and its `loading.tsx` returned `null`, so the page had no cacheable initial shell at all
  

**The diagnostic**: Don't trust the hit ratio. `curl` the route, check `x-vercel-cache: HIT`, then read the HTML. If your hero isn't in it, your cache is serving a spinner.

## 4\. Cache-key cardinality: the cookies and flags you forgot about

Caching only helps if requests actually share entries. Two audit findings show how easily that stops being true.

In one audit, a prebuilt, cached product-listing route existed in a production proxy, and almost no real visitor ever hit it. It doesn't even read as a bug: the team had built the cached route properly (prebuilt params, day-long cache lifetimes), and the proxy routed clean category URLs to it only when the request carried no cookies, on the theory that any cookie might mean personalization.

But real visitors always carry cookies (consent, locale, analytics), so the "safe" heuristic sent effectively 100% of real users to dynamic rendering of a page that was already cached. The cached route only ever served first-time, cookie-less hits.

```tsx
// Looks defensive; actually disables the cache for every real visitor.
const isCleanCategoryUrl = isCategoryPath(request) && !request.nextUrl.search;

if (isCleanCategoryUrl && request.cookies.size === 0) {
  return rewriteToStatic(request); // almost never reached in production
}
return rewriteToDynamic(request);
```
```tsx
// Bypass the cache only for cookies which ACTUALLY change the document
// (login, pricing tier; not consent, locale, or analytics)
const RENDER_COOKIES = ['session', 'logged-in', 'price-tier'];
const personalized = RENDER_COOKIES.some((c) => request.cookies.has(c));

if (isCleanCategoryUrl && !personalized) {
  return rewriteToStatic(request);
}
return rewriteToDynamic(request);
```

The mirror image: another team's middleware evaluated all 40 feature flags per request, precomputed a personalization code from the full set, and prepended it to the path. Cache cardinality became route × locale × every flag combination, even when a route used one or two flags, so reuse dropped and regeneration multiplied. This is "combinatorial explosion": too many variants to be worth caching.

The same principle at smaller scale: a root layout forced dynamic by a `sidebar_collapsed` cookie, a preference nobody needed the server for.

**The rule**: Only inputs that change the rendered document belong in the cache key or the bypass condition. Everything else moves below the cache boundary.

| Input                                     | Where it belongs                      |
| ----------------------------------------- | ------------------------------------- |
| Session, login state, pricing tier        | Cache key or bypass condition         |
| Flags a route actually reads and renders  | Cache key, scoped to that route       |
| Consent, locale, and analytics cookies    | Ignored by the cache boundary         |
| UI preferences (like `sidebar_collapsed`) | Client leaf components                |
| Flags a route doesn't read                | Nowhere; exclude them from precompute |

## 5\. Nobody owns the cache

The least glamorous finding, and arguably the root cause of the other four. In audit after audit we find four or five cache layers (route cache, `fetch` cache, an instance-local Apollo cache, Redis, a CDN) and no answer to the question: who decides how fresh this data is, and who invalidates it?

Concrete shapes this takes:

- One commerce platform had no `unstable_cache` anywhere in the codebase: browser Axios outside the server cache, Apollo `cache-first` per instance, and a `/api/revalidate/all` that broadly invalidated the root layout
  
- One global site had 2 database calls using the Data Cache and 3 cache tags total, so countries, categories, and navigation refetched on every rebuild
  
- One team hit stale data after toggling one `revalidate` value. Route-level revalidate, `unstable_cache`, shared `fetch` options, and `not-found.tsx` all interacted so that the most restrictive path governed route freshness.
  

The pattern we recommend instead, and have rolled out as target-state guidance with audit customers, is a data-access layer that owns caching:

```tsx
// the DAL owns the cache, not the component
import { cacheTag, cacheLife, updateTag } from 'next/cache';

export async function getProduct(slug: string) {
  'use cache';
  cacheTag(`product:${slug}`);
  cacheLife('hours');
  return db.products.find(slug);
}

// Every write pairs with its invalidation
export async function saveProduct(slug: string, data: Product) {
  'use server';
  await db.products.update(slug, data);
  updateTag(`product:${slug}`); // read-your-own-writes
}
```

The contract this establishes:

- **One cache authority per render path**: the data-access layer decides freshness, not scattered fetch options
  
- **Semantic, entity-specific tags**: `product:${slug}`, not `product-data`
  
- **Writes pair with invalidations**: `updateTag` for a user's own writes, `revalidateTag(tag, 'max')` for webhooks
  
- **Never cache** preview, auth, or cart data
  

## The low-effort fixes we find almost every time

Three findings appear so often they're worth checking before any deeper work.

### Your 404 page is a compute product

One audit found a 404 that took about 2.7s to execute, made numerous uncached async calls, and returned a 200: every miss was a full dynamic invocation and a DDoS-shaped cost risk. In another audit, the 404 page actually returned a 500, rendered dynamically, and consumed compute on every hit.

### Static files leaking into dynamic routes

We've found favicons served through a dynamic catch-all with async calls inside, a favicon flagged high-impact on usage because it consumed compute instead of being served as a static file from the CDN, and `/favicon.ico` rendering through a dynamic not-found path.

### Unprotected cache-purge endpoints

Revalidation endpoints wipe your cache, and a wiped cache means every request re-renders from scratch. That makes an unprotected purge endpoint a free denial-of-service button: in one audit, a CMS webhook with no authentication could trigger 150 seconds of cache invalidation per call, and anyone could call it.

More often the auth check exists but has holes: a few purge routes missing a bearer-token check their 40 sibling routes had, or an auth check wrapped in a feature flag that defaulted to false, so the check vanished. These endpoints should fail closed: if anything about the auth check is missing or broken, reject the request rather than waving it through.

```tsx
// auth behind a flag that defaults to false
if (await flags.enableApiRevalidateAuthentication()) checkAuth(req);
```
```tsx
// fail closed, before any parsing or work
export async function POST(req: Request) {
  if (!isAuthorized(req)) return new Response(null, { status: 401 });
  // ...validate targets, then revalidateTag()
}
```

Your cache invalidation surface is a public-facing API. It deserves the same auth rigor as your checkout or admin routes.

## How we audit caching

Every finding in our audits follows a consistent sequence: measure, then move. The teams we audit almost always are caching; what they lack is the process to monitor what the cache is doing.

| Antipattern               | Symptom                                    | Diagnostic                                            |
| ------------------------- | ------------------------------------------ | ----------------------------------------------------- |
| 1\. Write amplification   | ISR writes rival or exceed reads           | Compare writes to reads per route in the dashboard    |
| 2\. Deploy-wiped caches   | Hit ratio saws down after every release    | Overlay deploy timestamps on the hit ratio graph      |
| 3\. Cached spinner shells | Cache HITs, but LCP stays slow             | `curl` the route and read the cached HTML             |
| 4\. Cache-key cardinality | Cached routes almost never get hit         | Measure the HIT share on routes with a cached variant |
| 5\. Nobody owns the cache | Stale data, conflicting freshness controls | Inventory every cache layer and its invalidation path |

One lesson generalizes across engagements: newer caching primitives are powerful, but Cache Components is not an instant cost fix. No caching API rescues an invalidation strategy nobody owns. Get the contract right (what layer caches, how long, who purges, balanced cardinality) and the primitives become easy to adopt.

### Try with an AI coding agent

You can run this audit yourself by giving this prompt to a coding agent such as Claude Code or Cursor. The agent uses the Vercel CLI to pull metrics, inspect responses, and check your code for each antipattern, then reports findings.

Before you start, install the [Vercel CLI](https://vercel.com/docs/cli) and link your local project directory with `vercel link`. The prompt instructs the agent to work read-only: unless you edit it, the agent won't purge caches, call revalidation endpoints, or deploy your project.

### Agent prompt

```txt
# Caching audit: verify the five antipatterns

You are auditing this Next.js repository and its linked Vercel project for the five
caching antipatterns from Vercel's caching audits. Work read-only: never purge
caches, never call revalidation endpoints, never deploy. Prefer `--format json` on
all `vercel` commands and cite evidence (file:line, metric values, response headers)
for every finding.

## Preflight
1. Confirm CLI auth and project link: `vercel whoami`, check `.vercel/project.json`
   (run `vercel link` if missing). Record the production domain.
2. Check metrics access: `vercel metrics list --format json`. If ISR/request metrics
   are unavailable (requires Observability Plus), say so, skip the metric steps, and
   run the code + HTTP checks only. Do not guess numbers you can't read.
3. Discover fields before querying: `vercel metrics schema vercel.request --format json`
   and `vercel metrics schema vercel.isr_operation --format json`.
4. Detect Next.js version and whether `cacheComponents` is enabled in next.config —
   this changes which fixes apply (segment configs vs `use cache`).

## Antipattern 1 — Write amplification
Metrics (last 24h and 7d, production):
- Cached reads per route:
  `vercel metrics vercel.request.count -f "environment eq 'production' and (cache_result eq 'HIT' or cache_result eq 'STALE')" --group-by route -a sum --since 24h --format json`
- ISR writes per route:
  `vercel metrics vercel.isr_operation.write_units -f "environment eq 'production'" --group-by route -a sum --since 24h --format json`
- Compute write utilization (cached reads ÷ writes) per route. Flag any route near
  or below 1:1. Flag catch-all routes (`[...slug]`) with high unique-path write
  volume — bots minting durable entries for junk URLs.
Code check:
- `force-static` (or `use cache`) on 404-only catch-alls.
- Revalidation endpoints: recursive/parent fan-out, bare content-type tags
  (`revalidateTag('product')` vs entity tags like `product:${slug}`), cron-based
  purges, purges of data not actually rendered in the cached shell.
- Time-based `revalidate` values much shorter than actual content change frequency.
When reporting: the fix is never "remove caching" — it's restructuring so more of
the page is cacheable with less frequent revalidation, delivering the rest
dynamically. Recommend instrumentation before restructuring: don't propose
narrowing tags until the data shows invalidation breadth is the driver.

## Antipattern 2 — Deploy schedule is the cache policy
- Deploy cadence: `vercel ls --prod` (or `vercel deployments list`) — count
  production deploys over the last 7d.
- Hit ratio over time: `vercel metrics vercel.request.count --group-by cache_result
  --since 7d --granularity 1h --prod --format json`. Correlate MISS spikes with
  deploy timestamps. If the ratio saws down at every release, report it — the
  deploy schedule, not the cache config, is the effective cache policy.
Code check:
- Data fetches that could survive deploys but don't: fetches missing
  `cache: 'force-cache'` with `next: { revalidate, tags }` for shared,
  non-personalized data (Vercel Runtime Cache).
- Cache keys derived from build/commit IDs (`GIT_SHA`, `VERCEL_GIT_COMMIT_SHA`,
  build timestamps) in cache wrappers — deployment-based key churn as an
  invalidation strategy.
Recommendation to include when cadence is high (many production deploys/day on a
latency-critical app): a staging deployment on merges to main, with manual
promotion to production, to shield the production cache from constant wipes.

## Antipattern 3 — The cache is hitting but serving a spinner
HTTP check: `curl -s -D - https://<prod-domain>/<route>` for the 5–10
highest-traffic routes (from `vercel metrics vercel.request.count --group-by route
-a sum --since 7d --prod --format json`). For each response with `x-vercel-cache: HIT`:
- Verify the HTML contains the primary content: an `<h1>`, hero copy, nav links,
  footer. Flag responses that are mostly skeleton/spinner markup or where visible
  text is < ~2KB on a content page.
Code check:
- `useSearchParams()` / `usePathname()` read high in the tree (page/layout level)
  when only a leaf component needs the value — the Suspense boundary is forced
  above the whole page, so the cached shell is just its fallback.
- Route-wide Suspense boundaries or `loading.tsx` that return `null` or a bare
  spinner on cached routes.
- Primary content fetched client-side (axios/SWR/React Query/Redux hydration) on
  cached routes — the shell caches without it, invisible to crawlers and AI
  answer engines.
- `await connection()`, `cookies()`, `headers()` at the top of cached pages.
Verdict per route: "cache serves real content" or "cache serves a shell" + why.

## Antipattern 4 — Cache-key cardinality
Code check (proxy.ts on Next 16, middleware.ts on 14/15):
- Cookie gates: any condition that bypasses a cached route when *any* cookie is
  present. List which cookies actually change the rendered document; flag
  consent/locale/analytics cookies used as bypass reasons.
- Flags: `precompute()` called with the full flag registry instead of a
  route-scoped subset; personalization codes prepended to every path
  (combinatorial explosion: route × locale × every flag combination).
- `cookies()` reads in root layouts for UI-only preferences.
Metrics cross-check: for routes with a cached variant, measure what share of
production requests actually hit it (`cache_result` distribution per route). A
prebuilt route with ~0% HIT share is a bypassed cache.
Rule to apply: only inputs that change the document belong in the cache key or
bypass condition; everything else moves below the cache boundary into leaf
components or Suspense slots.

## Antipattern 5 — Nobody owns the cache
Inventory every caching layer in the repo and report them in one table:
`fetch(..., { next: ... })` options, `unstable_cache`, `'use cache'` /
`cacheTag` / `cacheLife`, `React.cache()`, Redis/KV clients, Apollo/client
caches, CDN `Cache-Control` headers, and any third-party CDN config.
- Count distinct cache tags; flag generic tags and layers with zero tags.
- For every write path (Server Action, webhook), verify it pairs with an
  invalidation (`updateTag` for user-own-writes, `revalidateTag(tag, 'max')`
  for webhooks on Next 16.3+; single-arg `revalidateTag` is deprecated).
- Check for conflicting freshness controls on the same render path (route-level
  revalidate + `unstable_cache` + shared fetch options + `not-found.tsx`): the
  most restrictive path can govern route freshness.
- Confirm preview, auth, and cart data are never cached.
- Report who owns freshness per data domain; "nobody" is a finding.

## Low-effort fixes (always check)
- `curl -s -o /dev/null -w "%{http_code}" https://<prod-domain>/favicon.ico`
  and a garbage URL: both should be static/CDN-served with correct status codes
  (404 must return 404, not 200 or 500) and no function invocation (check
  `x-matched-path` / `x-vercel-cache`, and `vercel logs` cache reasons).
- Middleware/proxy matcher: confirm static assets and files with extensions are
  excluded.
- Redirects implemented in compute that could be `vercel.json` / next.config
  redirects.
- Every `/api/revalidate*` handler checks auth before parsing or doing work,
  fails closed, and never gates the auth check behind a feature flag. Flag any
  handler missing checks its siblings have.

## Report format
Rank findings by Impact (High/Med/Low) × Effort (Low/Med/High), highest
impact-per-effort first. For each: antipattern number, evidence (metric values,
headers, file:line), the specific fix with a code sketch, and expected effect
(hit ratio, write units, function invocations). Put anything you couldn't prove
with data under "Needs more evidence" instead of guessing.
```

## Next steps

- Learn how the [Runtime Cache](https://vercel.com/docs/caching/runtime-cache) persists data fetches across deployments
  
- Review how [Vercel's caching layers](https://vercel.com/docs/caching) fit together
  
- Understand [Incremental Static Regeneration](https://vercel.com/docs/incremental-static-regeneration) and how deployments affect it
  
- Read the Next.js references for [`cacheTag`](https://nextjs.org/docs/app/api-reference/functions/cacheTag), [`updateTag`](https://nextjs.org/docs/app/api-reference/functions/updateTag), and [`revalidateTag`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
  
- Learn more about [what a Forward Deployed Engineer does](https://www.youtube.com/watch?v=48LKTOtJ7FU)