Designing an Invalidation Tag Taxonomy
Tag-based invalidation is only as good as its vocabulary. Start with ['Todo'] for everything and within a few months every mutation invalidates every todo query, which is functionally identical to clearing the cache — correct, and slow enough that people start disabling refetches to compensate. This page covers designing a tag set that stays precise as the application grows, under Tag-Based Invalidation Systems. The RTK Query mechanics are covered in Invalidating RTK Query With providesTags, and the Apollo equivalent in Implementing Tag-Based Invalidation in Apollo; this one is about the vocabulary all three share.
Prerequisites
- A list of the entity types your API exposes.
- A list of your mutations and, for each, what it actually changes.
- Some way to count invalidated entries — the lifecycle log from Logging Query Lifecycle Events for Debugging is enough.
Step 1 — Close the Tag Union
Tags invented at the call site drift. A closed union makes a typo a compile error and a new tag a deliberate act.
export const TAG_TYPES = ['Todo', 'Board', 'Comment', 'User', 'Workspace'] as const;
export type TagType = (typeof TAG_TYPES)[number];
/** The sentinel that means "the membership of this collection", not any member. */
export const LIST = 'LIST' as const;
export interface Tag {
type: TagType;
id: string | typeof LIST;
}
export const tag = (type: TagType, id: string | typeof LIST = LIST): Tag => ({ type, id });
/** Every query returning a collection uses this shape. */
export function collectionTags<T extends { id: string }>(type: TagType, rows: T[] | undefined): Tag[] {
return rows
? [...rows.map((row) => tag(type, row.id)), tag(type, LIST)]
: // On error there are no rows, but the collection tag must still be provided
// or a later invalidation cannot reach this entry to retry it.
[tag(type, LIST)];
}
Cache Behavior Analysis: Providing Todo:LIST even when the query failed is the detail people miss: an errored entry with no tags cannot be invalidated, so a subsequent mutation has no way to prompt a retry and the screen stays broken until something else refetches it. Returning one tag per row means an update to a single todo invalidates the list query that contains it and that todo’s detail query, with one tag and no coordination between them. The closed union is what makes an audit possible at all — with free-form strings you cannot enumerate the vocabulary, so you cannot tell whether Todos and Todo are two tags or one typo.
Step 2 — Separate Entity Tags From Collection Tags
The distinction is whether a change alters a record or alters membership. Mutations should name only what they change.
export const todoApi = {
// Reads
listTags: (rows: Todo[] | undefined) => collectionTags('Todo', rows),
detailTags: (id: string) => [tag('Todo', id)],
// Writes — each names exactly what it changes.
update: (id: string) => [tag('Todo', id)], // record changed
create: () => [tag('Todo', LIST)], // membership changed
remove: (id: string) => [tag('Todo', id), tag('Todo', LIST)], // both
// A move between boards changes todo membership on two boards, and the
// todo itself. Naming all three is precise, not excessive.
move: (id: string, fromBoard: string, toBoard: string) => [
tag('Todo', id),
tag('Todo', LIST),
tag('Board', fromBoard),
tag('Board', toBoard),
],
};
Cache Behavior Analysis: A create invalidating only Todo:LIST leaves every cached Todo:t* detail entry untouched, so opening a todo the user had already viewed is still instant after someone else adds an unrelated one — with a single Todo tag, that create would refetch every detail query in the cache. The delete case genuinely needs both tags because it changes a record’s existence and the collection’s membership. Multi-tag mutations like move are where a taxonomy earns its keep: naming four precise tags refetches four entries, whereas the lazy alternative of invalidating both whole types refetches everything either board has ever loaded.
Step 3 — Derive Provided Tags From the Response
Tags built from request arguments describe what you asked for. Tags built from the response describe what you got, which is what invalidation needs to match.
// ✗ Request-derived: the list is tagged with the filter, not its contents.
const badListTags = (filters: TodoFilters) => [tag('Todo', JSON.stringify(filters))];
// ✓ Response-derived: every row the query actually returned is named.
export function providedTags(response: Todo[] | undefined, error: unknown): Tag[] {
if (error) return [tag('Todo', LIST)];
return collectionTags('Todo', response);
}
// The same idea in TanStack Query, where "tags" are key prefixes and the
// registry maps a tag to the keys that hold it.
export function buildTagIndex(client: QueryClient) {
const index = new Map<string, Set<string>>();
for (const query of client.getQueryCache().getAll()) {
const rows = query.state.data as { id: string }[] | undefined;
if (!Array.isArray(rows)) continue;
for (const row of rows) {
const key = `Todo:${row.id}`;
(index.get(key) ?? index.set(key, new Set()).get(key)!).add(query.queryHash);
}
}
return index;
}
Cache Behavior Analysis: A request-derived tag such as Todo:{"status":"active"} cannot be matched by a mutation, because the mutation knows it changed todo t2 and has no way to determine which filter combinations t2 appeared in. Response-derived tags invert that: the list query itself declares that it contains t2, so invalidate(Todo:t2) reaches it without either side knowing about the other. In TanStack Query, where invalidation matches key prefixes rather than tags, the equivalent is either to keep a tag index like the one above or to accept prefix granularity — which is why libraries with explicit tags handle the “which lists contain this record” problem more directly.
Step 4 — Audit the Blast Radius
A taxonomy degrades one convenient widening at a time. Measuring makes the degradation visible before it is normal.
export function measureBlastRadius(client: QueryClient) {
const radii: Array<{ mutation: string; entries: number }> = [];
client.getMutationCache().subscribe((event) => {
if (event.type !== 'updated' || event.mutation?.state.status !== 'success') return;
const name = String(event.mutation.options.mutationKey?.[0] ?? 'anonymous');
// Count how many entries the invalidation that follows actually marks stale.
let count = 0;
const stop = client.getQueryCache().subscribe((queryEvent) => {
if (queryEvent.type === 'updated' && queryEvent.action.type === 'invalidate') count += 1;
});
// Invalidation happens synchronously in onSettled; a microtask is enough.
queueMicrotask(() => { stop(); radii.push({ mutation: name, entries: count }); });
});
return () => radii;
}
Cache Behavior Analysis: Counting invalidate actions rather than subsequent fetches is the right measurement, because an invalidation with refetchType: 'none' marks entries stale without producing requests and would otherwise look free — it is not free, it costs those entries their freshness. The microtask boundary works because invalidation from onSettled runs synchronously within the mutation’s settle cycle, so anything scheduled after the current task has captured the full set. Recording the mutation key rather than the entry keys keeps the output small enough to ship as a metric; a mutation whose median radius doubles between releases is the signal that someone widened a tag to make a symptom go away.
Edge Cases & Gotchas
Cross-type relationships. Adding a comment changes the comment collection and a todo’s comment count. Naming both is correct; adding a Comment tag to every todo query is not — that makes every comment invalidate every todo.
Paginated collections. A LIST sentinel invalidates every page of a paginated collection, which is usually right, since inserting a record shifts every subsequent page. Where pages are cursor-stable, a PAGE:<cursor> tag is a legitimate refinement — see Merging Paginated Lists Without Duplicates.
Server-driven tags. Returning the tags a mutation affected in its response is the most precise option available, because the server knows exactly what it wrote. It also couples your client vocabulary to your API contract, so version the tag names if you take this route.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Creating one record refetches every detail query | The collection and its members share one tag | Add a LIST sentinel and invalidate only it on create |
| A mutation cannot reach the list containing the record | Tags are derived from request arguments | Derive provided tags from the response rows |
| An errored query never recovers after a fix | The failed query provided no tags | Provide the collection tag even on error |
| Two spellings of one tag coexist | Tags are free-form strings | Close the tag type union |
| Invalidation cost grows release over release | Tags were widened to fix symptoms | Track blast radius per mutation as a metric |
Frequently Asked Questions
What is the LIST sentinel for?
It gives a collection an identity that is distinct from its members. A list query provides one tag per row plus Todo:LIST, so there are two independent things a mutation can name: “this record changed” and “which records are in this collection changed”. A create alters membership without touching any existing record, so it invalidates only LIST and every cached detail entry survives. Without the sentinel there is only one tag for the whole type, and every mutation of any kind refetches everything.
Should tags encode filters?
No — tags identify data, not queries. Encoding a filter produces a tag per filter combination, which explodes the vocabulary, and it creates an unsolvable matching problem: a mutation that updated todo t2 has no way to know which filter combinations t2 currently satisfies, or which it satisfied before the change. The LIST sentinel handles all filtered views of a collection at once, and if that is too coarse the answer is usually a narrower entity tag rather than a finer collection tag.
How do I keep a taxonomy from degenerating?
Measure the blast radius per mutation and treat it as a metric with a budget. Degeneration is never a decision — it is a sequence of individually reasonable bug fixes, each widening one tag because a screen was not updating, and each making the next widening easier to justify. A number in a dashboard makes the trend visible while it is still cheap to reverse, and it gives reviewers something concrete to point at when a pull request changes invalidatesTags from one entry to a whole type.
Related
- Tag-Based Invalidation Systems — the parent guide on tag-driven cache invalidation.
- Invalidating RTK Query With providesTags — the RTK Query implementation of this vocabulary.
- Implementing Tag-Based Invalidation in Apollo — the same ideas over Apollo’s normalized cache.
- Invalidating Queries With a Key Factory — how prefix matching provides tag-like granularity in TanStack Query.