open atlas
↑ Back to track
Next.js, zero to senior NEXT · 09 · 03

Cache tags by domain: invalidation taxonomy, webhook wiring, and the over-invalidation storm

revalidateTag is only as strong as your tag taxonomy: entity tags, collection tags, prefixes per bounded context. One global tag turns every CMS edit into a site-wide purge storm; a missing collection tag leaves stale prices for hours. The webhook-to-tags mapping table is code.

NEXT Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

The storefront migration ships with one cache rule, written in a hurry: every fetch carries the tag cms, “so we never serve stale content.” The webhook from the CMS calls revalidateTag('cms') on any edit. It works in the demo. Three weeks later an editor fixes a typo in the footer at 11:40, and the dashboard turns red: cache hit ratio drops from 96 percent to 4, every page on the site regenerates at once, the CMS API rate-limits at 50 rps and starts returning 429s, and p99 TTFB climbs to eight seconds — for a typo. Editors make about sixty edits a day, so this is not an incident, it is the new weather. The sister team made the opposite bet: only fine-grained per-product tags. Their price import updated product pages instantly — while category listings showed the old price for six more hours, until the time-based revalidate happened to expire. Both teams had working code and a broken language: nobody designed what the tags mean.

revalidateTag mechanics: invalidation by meaning, not by URL

Before you write your first revalidateTag call, ask: does the tag name say what changed, or just that something did? The answer determines whether a footer typo costs you one page or the whole site.

A tag is attached when the cache entry is written — fetch(url, { next: { tags: ['catalog:product:42'] } }) — and revalidateTag('catalog:product:42') marks every entry carrying that tag stale, wherever it was used: product page, search result, home carousel. That is the power over revalidatePath: you invalidate by domain meaning, not by enumerating URLs that might embed the data. The semantics are worth stating precisely. revalidateTag does not regenerate anything eagerly — it marks entries stale, and the next request for an affected page re-fetches and rebuilds (with the stampede characteristics you met in the ISR lesson: many concurrent misses hit the origin together). Called inside a Server Action, it additionally refreshes the client’s router view on completion; called from a route handler — the webhook case — pages refresh on their next visit. One more boundary: tags ride on the Data Cache, so they apply to fetch entries and to functions you explicitly cache with tags (unstable_cache today) — a raw ORM query with no caching wrapper is invisible to the whole mechanism.

// lib/data/products.ts
export async function getProduct(id: string) {
  const res = await fetch(`${CMS_URL}/products/${id}`, {
    next: { tags: [`catalog:product:${id}`, 'catalog:products'] },
  });
  return res.json();
}

Taxonomy: entity, collection, bounded context

Most invalidation incidents trace back to one missing design decision: nobody asked “when X changes, which surfaces must update?” — before the first tag was written. These three species give you the vocabulary to answer it.

Three tag species cover almost everything. An entity tagcatalog:product:42 — names one row’s truth; it belongs on every fetch that returns that entity’s data. A collection tagcatalog:products — names membership and ordering: any fetch whose result would change when a product is created, deleted, or re-ranked carries it. And composite pages carry several: the home page might hold catalog:products, cms:page:home, and pricing:campaign:summer. The prefix is not decoration — it is the bounded context, and the naming convention is architecture: catalog:, cart:, cms:, pricing: mirror team ownership, make collisions impossible, and make grep answer “who can purge what.” The design test for any tag is one question: when X changes, exactly which surfaces must re-render? If you cannot answer it, the tag is not designed yet.

The two failure modes are symmetric. Over-invalidation — the Hook’s global cms tag — means every edit purges everything: hit ratio collapses, regeneration storms hammer origins precisely when traffic is present, and rate-limited upstreams turn a purge into an outage. Under-invalidation — entity tags only — means correctness bugs with business consequences: the price changed, the product page says so, and the category listing quotes the old number for hours. Wrong prices have refund and legal weight, not just aesthetic weight. The working heuristic: detail surfaces get the entity tag, every list-shaped fetch gets a collection tag, and write paths that change membership-relevant fields must emit both.

Quiz

Every fetch on the site carries the single tag cms, and the CMS webhook calls revalidateTag('cms') on any edit. An editor fixes a footer typo. What actually happens?

Order the steps

Order the steps to design a sound cache-tag taxonomy for a new bounded context before writing any fetch() calls:

  1. 1 Map each write event to exactly which surfaces must change — entity pages, collection listings, composite pages separately
  2. 2 Choose a bounded-context prefix (e.g. 'catalog:') so tags from different teams can never collide and ownership is grep-able
  3. 3 Define entity tags (one per row, e.g. 'catalog:product:42') and collection tags (one per membership-changing operation, e.g. 'catalog:products')
  4. 4 Attach the correct tag set on every fetch() or unstable_cache call in the data layer
  5. 5 Write the TAG_MAP in the webhook handler: event type maps to the minimum set of tags — verify with a control assertion that an unrelated page stays HIT

Webhooks → tags: the mapping table is code

The CMS does not speak tags; it speaks events. The translation lives in one route handler, and writing it as a literal mapping table is the difference between an invalidation policy you can review and one you reverse-engineer during incidents. Two non-negotiables around it: verify the webhook signature — an unauthenticated revalidate endpoint is a free denial-of-service lever, one curl loop from a cold cache — and make unknown event types loud (log and alert) instead of silently dropping or, worse, falling back to a global purge.

// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache';
import { NextResponse } from 'next/server';
import { verifySignature } from '~/lib/webhooks';

const TAG_MAP: Record<string, (p: Record<string, string>) => string[]> = {
  'product.updated': (p) => [`catalog:product:${p.id}`],
  'product.priced':  (p) => [`catalog:product:${p.id}`, 'catalog:products'],
  'product.created': ()  => ['catalog:products'],
  'page.published':  (p) => [`cms:page:${p.slug}`],
};

export async function POST(req: Request) {
  const event = await verifySignature(req); // 401 on failure — purge is a privilege
  const toTags = TAG_MAP[event.type];
  if (!toTags) return NextResponse.json({ error: 'unmapped event' }, { status: 422 });
  const tags = toTags(event.payload);
  tags.forEach((t) => revalidateTag(t));
  return NextResponse.json({ revalidated: tags });
}

Read the table as policy: a content edit touches one entity; a price change touches the entity and the collections that display prices; a creation touches only collections (no entity page is cached yet). When the nightly price import updates 2,000 products, the mapping emits 2,000 entity tags plus one collection tag — detail pages refresh lazily as visited, listings refresh once. That is the storm-free shape.

The newer model, and how to test the one you have

A newer direction exists and deserves an honest label: the 'use cache' directive with cacheTag() and cacheLife() lets any function or component become a cached, taggable unit — DB queries included, no fetch required. As of now it sits behind experimental flags on canary builds; learn its shape, but build on the stable path — fetch tags plus unstable_cache for non-fetch reads — and isolate tagging inside your data layer so the migration later is a wrapper swap, not a rewrite.

Whatever the mechanism, invalidation is testable, and the test is a staging checklist with both assertions: after firing a product.priced fixture webhook, the product page MUST change, the category listing MUST change — and the about page MUST NOT (watch the cache-status response header flip MISS/STALE on the first two and stay HIT on the third). The must-not-change control is the assertion teams forget, and it is the only automated detector of over-invalidation: hit ratio graphs tell you a week later; the control page tells you in CI.

Quiz

The nightly price import fires product.updated events, mapped to entity tags only. Next morning, product pages show new prices but category listings quote yesterday's numbers for hours. What is the actual defect?

Recall before you leave
  1. 01
    Name the two invalidation failure modes and the heuristic that avoids both.
  2. 02
    What exactly happens when revalidateTag fires, and what does it never do?
Recap

Cache tags are the vocabulary your system uses to say what changed, and both storefront teams failed by skipping the vocabulary design: one global tag turned every footer typo into a site-wide purge storm against a rate-limited CMS, and entity-only tags left category listings quoting yesterday’s prices for hours. The mechanics are small: tags attach when a Data Cache entry is written, revalidateTag marks every carrying entry stale immediately, regeneration happens on the next demand per page — stampedes included — and a Server Action additionally refreshes the calling client while a route handler leaves refresh to the next visit. Raw ORM reads outside the Data Cache are invisible to all of it, which is why tagging belongs in the data layer. The taxonomy that works has three species: entity tags for one row’s truth, collection tags for membership and ordering, and composite pages carrying several — all namespaced by bounded context, catalog: and cart: and cms:, so ownership, collision-safety, and grep-ability come from the naming convention itself. The webhook handler is where events become tags, and writing it as a literal mapping table makes invalidation policy reviewable: price events emit entity plus collection, creations emit collection only, unknown events fail loudly, and the signature check keeps the purge lever from being a public DoS endpoint. The use cache and cacheTag direction will eventually make any function taggable, but it is experimental — build on fetch tags and unstable_cache, isolated behind your data layer. And test invalidation like behavior: fire the fixture webhook in staging, assert the pages that must change, and — the forgotten half — assert the control page that must not. Now when you review a webhook handler or a new fetch call, check the tag list against the taxonomy: entity tag for this row, collection tag for every list that reflects it, and a control page in your staging checklist that must stay HIT.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.