Tag-Based Invalidation Systems
Query-key invalidation breaks the moment a component hierarchy grows beyond a single file. You rename a key segment, add a new filter parameter, or extract a shared hook — and a mutation somewhere stops invalidating the right queries because the string no longer matches. Tag-based invalidation solves this by decoupling what to invalidate from how queries are keyed: you attach semantic resource tags to cached data at write time and invalidate by tag identity after a mutation, regardless of the specific query keys involved.
This page sits under Cache Invalidation & Server Synchronization, which covers the broader problem space of keeping client state consistent with the server. For the SWR-specific time-based companion to the pattern described here, see Background Refetch Strategies. For the mutation-side of the equation — optimistic updates and rollback — see Mutation Sync & Rollback. The GraphQL-specific wiring for this system is covered in detail in Implementing Tag-Based Invalidation in Apollo.
Diagnostic Checklist
You are in the right place if you observe any of the following after a mutation:
- Stale list data persists on screen despite a successful
POSTorPATCH— a query that shares resource data with the mutated endpoint was not invalidated. - Different components displaying the same entity (e.g., a user card in a sidebar and a user row in a table) show divergent values after an update.
- Renaming or restructuring a query key in one file broke invalidation logic in five other files that used
invalidateQueries(['users']). - A broad
invalidateQueries({ queryKey: ['users'] })triggers refetches for unrelated queries that happen to share the same key prefix. - Cache invalidation logic is duplicated across multiple
onSuccesshandlers with subtle variation in the string keys used. - A multi-tenant application shows data from tenant A to a user logged in as tenant B after a cache warm-up.
Prerequisites
Before implementing tag-based invalidation, confirm you understand the following:
- Query key structure — how
queryKeyarrays work in TanStack Query v5 and why key prefix matching can cause over-invalidation. - Mutation lifecycle hooks —
onSuccess,onError, andonSettledfire in a specific order;onSettledis the correct hook to dispatch invalidation because it fires regardless of outcome. - Stale-while-revalidate — understanding how
staleTimeandgcTimeinteract with invalidation tells you whether an invalidated query re-fetches immediately or waits for the next observer. - Cache layer architecture — knowing that the query cache is a separate layer from component state helps you reason about why invalidation does not trigger a synchronous re-render.
Implementation: Typed Tag Registry
Steps
- Create a singleton
TagRegistrymodule that maps tag strings to sets of active query keys. The registry is populated when a query mounts and cleaned up when it unmounts — never from the component body directly. - Define tag factory functions using a shared type so tags cannot be minted with ad-hoc string concatenation.
- Expose an
invalidateTagfunction that resolves the registry and delegates toqueryClient.invalidateQueries.
// lib/tag-registry.ts
import { QueryClient, QueryKey } from '@tanstack/react-query';
// Typed tag factories — the only permitted way to create tag strings
export const Tags = {
user: (id: string) => `user:${id}` as const,
userPosts: (userId: string) => `user:${userId}:posts` as const,
tenantUsers: (tenant: string) => `tenant:${tenant}:users` as const,
} satisfies Record<string, (...args: string[]) => string>;
type Tag = ReturnType<(typeof Tags)[keyof typeof Tags]>;
// Secondary index: tag → active query keys
const registry = new Map<Tag, Set<QueryKey>>();
export function registerQueryTag(tag: Tag, queryKey: QueryKey): void {
if (!registry.has(tag)) registry.set(tag, new Set());
registry.get(tag)!.add(queryKey);
}
export function deregisterQueryTag(tag: Tag, queryKey: QueryKey): void {
registry.get(tag)?.delete(queryKey);
if (registry.get(tag)?.size === 0) registry.delete(tag);
}
export async function invalidateTag(
queryClient: QueryClient,
tag: Tag,
): Promise<void> {
const keys = registry.get(tag);
if (!keys || keys.size === 0) return;
await Promise.all(
[...keys].map((key) =>
queryClient.invalidateQueries({ queryKey: key as unknown[] }),
),
);
}
Cache Behavior Impact. invalidateQueries marks every matched query as stale (sets isStale: true) and, if the query has an active observer (i.e. the component is mounted), immediately schedules a background re-fetch. Queries without active observers are only marked stale — they re-fetch the next time a component mounts and calls useQuery for that key. gcTime (default 5 minutes) determines how long an inactive stale query survives in memory before being garbage-collected.
Configuration Trade-offs
gcTimevs registry compaction — ifgcTimeis short (e.g.0), queries are evicted from the cache almost immediately after unmount, but the registry entry may linger ifderegisterQueryTagis not called in the unmount cleanup. Set agcTimethat is always shorter than your compaction cycle.structuralSharing— TanStack Query’sstructuralSharing: true(default) avoids re-rendering if the new network response is reference-equal to the cached value. Tag invalidation triggers a re-fetch but the component will not re-render if the server returns identical data.staleTimeinteraction — settingstaleTime: Infinityprevents background re-fetch on window focus but does not block explicitinvalidateQueries. Tag invalidation always works regardless ofstaleTime.
Implementation: Hook-Level Tag Binding
Steps
- Write a
useTaggedQueryhook that wrapsuseQuery, registers the query key with the supplied tags on mount, and deregisters on unmount. - Replace direct
useQuerycalls for resource-backed queries withuseTaggedQuery.
// hooks/use-tagged-query.ts
import { useEffect } from 'react';
import {
useQuery,
UseQueryOptions,
UseQueryResult,
QueryKey,
} from '@tanstack/react-query';
import { Tags, registerQueryTag, deregisterQueryTag } from '../lib/tag-registry';
type TagValue = ReturnType<(typeof Tags)[keyof typeof Tags]>;
interface TaggedQueryOptions<TData, TError, TKey extends QueryKey>
extends UseQueryOptions<TData, TError, TData, TKey> {
tags: TagValue[];
}
export function useTaggedQuery<
TData,
TError = Error,
TKey extends QueryKey = QueryKey,
>(
options: TaggedQueryOptions<TData, TError, TKey>,
): UseQueryResult<TData, TError> {
const { tags, queryKey, ...rest } = options;
useEffect(() => {
// Register this query key under each supplied tag
tags.forEach((tag) => registerQueryTag(tag, queryKey as QueryKey));
return () => {
// Clean up registry entries when the component unmounts
tags.forEach((tag) => deregisterQueryTag(tag, queryKey as QueryKey));
};
// queryKey is stable when built with queryOptions() or a memoised array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryKey, tags.join(',')]);
return useQuery({ queryKey, ...rest });
}
// Usage example
export function UserPostList({ userId }: { userId: string }) {
const { data } = useTaggedQuery({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetch(`/api/users/${userId}/posts`).then((r) => r.json()),
staleTime: 30_000,
tags: [Tags.userPosts(userId), Tags.user(userId)],
});
// ...
}
Cache Behavior Impact. The useEffect runs after the first render and after any render where queryKey or tags identity changes. The cleanup function runs before the component unmounts and before the effect re-runs on dependency change. This guarantees the registry never holds a reference to a key that the cache has already garbage-collected, preventing phantom invalidations from firing against evicted queries.
Configuration Trade-offs
tags.join(',')as dependency — passing the array directly causes a new reference on every render; joining to a stable string avoids unnecessary effect re-runs. If your tag factory returns the same string for the same inputs, this is safe.queryKeystability — ifqueryKeyis constructed inline withoutuseMemo, the array reference changes every render. Use TanStack Query v5’squeryOptions()helper to create a stable descriptor outside the component.- Concurrent Mode — effects do not fire during the initial render in Strict Mode’s double-invocation; the cleanup fires before the second invocation. This means the registry briefly holds a stale entry during Strict Mode dev cycles, which is harmless in production.
Implementation: SWR Tag Adapter
SWR uses string or tuple keys and does not expose a native tag abstraction. The pattern below simulates tags by encoding them into a composite key prefix and using SWR’s global mutate with a key-matching function.
Steps
- Encode tags into the SWR key at query definition time:
tag:user:42:/api/users/42/posts. - On mutation settle, call global
mutatewith a predicate that matches any key carrying the target tag prefix. - Pass
revalidate: trueso SWR re-fetches immediately rather than just clearing the cache.
// lib/swr-tag-invalidation.ts
import { mutate, Arguments } from 'swr';
export const swrTagKey = (tag: string, url: string) => `tag:${tag}:${url}`;
export async function invalidateSwrTag(tag: string): Promise<void> {
// SWR v2: function predicate receives the raw key
await mutate(
(key: Arguments) =>
typeof key === 'string' && key.startsWith(`tag:${tag}:`),
undefined, // data=undefined forces re-fetch rather than optimistic update
{
revalidate: true,
// Roll back to the pre-mutation snapshot if the re-fetch errors
rollbackOnError: true,
// Deduplicate concurrent invalidations within the same tick
populateCache: false,
},
);
}
// Usage in a mutation handler
async function updateUser(userId: string, patch: Partial<User>) {
await fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
// Invalidate all SWR queries tagged with this user
await invalidateSwrTag(`user:${userId}`);
await invalidateSwrTag(`tenant:acme:users`);
}
Cache Behavior Impact. SWR’s global mutate with a predicate function iterates every key held by the SWR cache and runs the predicate synchronously. This is O(n) over the SWR cache, unlike the registry-based O(1) approach above — for caches with thousands of entries, the predicate walk can block the main thread for a visible frame. Mitigate by keeping the SWR cache size bounded with dedupingInterval and by using the TanStack Query registry approach for large-scale applications.
Configuration Trade-offs
revalidate: truevspopulateCache: false—revalidate: truetriggers an immediate network request for every matched key. If you also setpopulateCache: false, SWR will not update the cache with optimistic data before the response arrives, which avoids a flash of stale UI between the mutation and the fresh server response.rollbackOnError: true— restores the pre-mutation snapshot if the re-fetch fails. This pairs well with the Mutation Sync & Rollback pattern where the mutation itself is applied optimistically before the invalidation fires.dedupingInterval— SWR deduplicates requests with the same key within this window (default 2000 ms). If your tag invalidation triggers the same key multiple times inside that window, only the first re-fetch fires. This is usually desirable; increase the interval to reduce redundant network work.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Mutation completes but list does not refresh | Tag registered on the list query does not match the tag emitted by the mutation handler — usually a capitalisation or delimiter difference (User:42 vs user:42). |
Centralise tag creation in the Tags factory. Add an integration test that asserts registry.get(Tags.user(id)) is non-empty after mounting the list component. |
| Invalidation fires for queries from a different tenant | Tag string does not include the tenant segment, so user:42 matches the same key across tenant namespaces. |
Prepend the tenant identifier to every tag: Tags.user(tenantId, userId). Validate the shape with a Zod schema at the tag factory boundary. |
useEffect cleanup fires before the query is removed from cache, leaving a phantom registry entry |
gcTime is set to 0, evicting the query from the cache before the cleanup runs. |
Set gcTime to at least 1000 ms on queries that use tag registration. The unmount cleanup fires synchronously, but gcTime: 0 schedules immediate eviction in the same microtask queue. |
Broad tag invalidation (tenant:acme:users) causes twelve simultaneous network requests and UI jank |
Multiple mounted components all have queries tagged with the broad scope. | Debounce the invalidateTag call by 50 ms using setTimeout / clearTimeout so rapid consecutive mutations collapse into a single invalidation pass. Use queryClient.cancelQueries before invalidateQueries to abort in-flight requests for those keys first. |
Frequently Asked Questions
How does tag-based invalidation differ from predicate-based invalidation in TanStack Query v5?
Predicate-based invalidation iterates every active query and runs a function against each Query object — the complexity is O(n) over the full cache. Tag-based invalidation uses a hash-map secondary index so the lookup is O(1) per tag, regardless of how many queries are active. The trade-off is extra memory for the registry and the discipline to clean it up on unmount. For caches under ~200 queries the difference is imperceptible; at thousands of concurrent queries the registry approach avoids measurable jank.
Can tag invalidation coexist with stale-while-revalidate background revalidation intervals?
Yes. Tags handle precision invalidation after mutations; refetchInterval handles time-based background refresh. Set a longer refetchInterval for low-churn resources and let tags do the surgical work after writes. The two mechanisms operate on different triggers — tag invalidation fires from onSettled, background revalidation fires on a timer — and they do not conflict. If both fire simultaneously, TanStack Query deduplicates the resulting network requests within its default dedupingInterval.
What is the safest tag naming convention to prevent cross-tenant cache collisions?
Use a colon-delimited hierarchy that always includes the tenant identifier as the first segment: tenant:acme:entity:123:relation. Generate tags exclusively from typed factory functions — never from inline string concatenation in component code. Validate the shape with a Zod schema at the API client boundary. This prevents a tag from tenant A accidentally matching a query registered under tenant B, which is a common source of data-leak bugs in multi-tenant SaaS applications.
How do you prevent an invalidation storm when a mutation touches a broad resource scope?
Narrow the tag scope to the smallest resource bucket the mutation actually changes. If you must invalidate a broad scope, debounce the dispatch by 50 ms so rapid successive mutations collapse into one network pass. In TanStack Query v5, call queryClient.cancelQueries({ queryKey: key }) before queryClient.invalidateQueries to abort any in-flight requests for those keys before issuing new ones. This avoids a race where a stale in-flight response overwrites the fresh data that the invalidation refetch returns.
Related
- Cache Invalidation & Server Synchronization — the parent section covering the full spectrum of invalidation strategies, from time-based to event-driven.
- Implementing Tag-Based Invalidation in Apollo — the GraphQL-specific recipe for wiring
typePolicies,keyArgs, andcache.evictinto a tag-driven workflow. - Mutation Sync & Rollback — how to apply optimistic updates before invalidation fires and roll back safely if the server rejects the change.
- Stale-While-Revalidate Implementation — the time-based complement to tag invalidation; controls how long stale data is shown before a background re-fetch completes.
- Background Refetch Strategies — polling and focus-based strategies that work alongside tag invalidation for resources that change without user-initiated mutations.