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.


Tag-based cache invalidation data flow A mutation fires, the tag registry resolves which query keys carry the affected tags, and those queries are invalidated so they re-fetch from the network. Mutation onSettled callback tags[ ] Tag Registry Map<tag, QueryKey[]> O(1) hash lookup pruned on unmount QueryKey[] Invalidation invalidateQueries() or mutate() (SWR) or cache.evict() (Apollo) API re-fetch e.g. user:42 · user:42:posts · tenant:acme:users Queries are marked stale and re-fetched only when their component is mounted and visible

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 POST or PATCH — 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 onSuccess handlers 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 queryKey arrays work in TanStack Query v5 and why key prefix matching can cause over-invalidation.
  • Mutation lifecycle hooksonSuccess, onError, and onSettled fire in a specific order; onSettled is the correct hook to dispatch invalidation because it fires regardless of outcome.
  • Stale-while-revalidate — understanding how staleTime and gcTime interact 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

  1. Create a singleton TagRegistry module 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.
  2. Define tag factory functions using a shared type so tags cannot be minted with ad-hoc string concatenation.
  3. Expose an invalidateTag function that resolves the registry and delegates to queryClient.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

  • gcTime vs registry compaction — if gcTime is short (e.g. 0), queries are evicted from the cache almost immediately after unmount, but the registry entry may linger if deregisterQueryTag is not called in the unmount cleanup. Set a gcTime that is always shorter than your compaction cycle.
  • structuralSharing — TanStack Query’s structuralSharing: 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.
  • staleTime interaction — setting staleTime: Infinity prevents background re-fetch on window focus but does not block explicit invalidateQueries. Tag invalidation always works regardless of staleTime.

Implementation: Hook-Level Tag Binding

Steps

  1. Write a useTaggedQuery hook that wraps useQuery, registers the query key with the supplied tags on mount, and deregisters on unmount.
  2. Replace direct useQuery calls for resource-backed queries with useTaggedQuery.
// 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.
  • queryKey stability — if queryKey is constructed inline without useMemo, the array reference changes every render. Use TanStack Query v5’s queryOptions() 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

  1. Encode tags into the SWR key at query definition time: tag:user:42:/api/users/42/posts.
  2. On mutation settle, call global mutate with a predicate that matches any key carrying the target tag prefix.
  3. Pass revalidate: true so 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: true vs populateCache: falserevalidate: true triggers an immediate network request for every matched key. If you also set populateCache: 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.