Skip to content
qt

Gotchas

Gotchas & when not to use query-tags

Tag-based invalidation solves a real problem, but it comes with tradeoffs. Read these before you hit them in production.

1

Tag collisions — two tags with the same serialized value

Tags are matched by their serialized value, not by where they were defined. Duplicate values silently over-invalidate.

normalizeTag(["list"]) and normalizeTag(["list"]) produce the identical string '["list"]'. If two unrelated domains use the same raw array, invalidating one will also invalidate the other.

// teams.ts
const teamTags = defineTags({
  list: () => ["list"],   // ❌  tag value is just ["list"]
})

// projects.ts
const projectTags = defineTags({
  list: () => ["list"],   // ❌  identical tag value!
})

// Invalidating teamTags.list() also invalidates projectTags.list()
// because both serialize to the same string: '["list"]'

Rule of thumb: always start the first element of a tag array with a domain prefix unique to that entity type ("todos", "projects", "billing-invoices"). Using defineTags() with one tree per domain makes collisions structurally impossible as long as keys differ at the root.

2

Scale — fan-out at high write volume

Each invalidation broadcasts an SSE event to every connected client. High-frequency writes can overwhelm the event pipeline.

The library batches invalidations within a configurable window (default batchWindow: 50 ms) to collapse concurrent writes. For workloads with hundreds of mutations per second, tune this upward. Very high fan-out scenarios ( thousands of simultaneous SSE connections receiving every event) benefit from a Redis broadcast adapter to distribute load across processes.

// Under high write load, increase batchWindow to collapse many
// concurrent invalidations into a single SSE event.
// Default is 50 ms. Tune based on your mutation rate.
const tagInvalidation = createTagInvalidationSystem({
  batchWindow: 200,  // up to 200 ms of invalidations merged into one event
})

When query-tags fits

  • Collaborative apps: moderate write volume, data freshness matters
  • Admin dashboards: low-to-medium traffic, broad invalidation is fine
  • User-scoped data: each scope only fans out to that user's connections

When to reconsider

  • High-frequency financial tickers or sensor streams (use WebSockets + dedicated feeds)
  • Extremely high write throughput (>1000 mutations/s) without user-level scoping
  • Pub/sub messaging systems — queries are the wrong abstraction
3

SSE heartbeat & proxy timeouts

Reverse proxies (nginx, Traefik, ALB, Cloudflare) close idle SSE connections. Heartbeats keep them alive.

The server sends a heartbeat comment (: heartbeat) every heartbeatInterval milliseconds (default 30 000 ms / 30 s). The response also sets X-Accel-Buffering: no and Content-Encoding: identity to prevent nginx and Traefik from buffering the stream. If your proxy has a shorter idle timeout than the heartbeat interval, the connection will drop silently.

# nginx — disable buffering on the SSE endpoint
location /api/invalidator {
    proxy_pass         http://app:3000;
    proxy_buffering    off;
    proxy_cache        off;
    proxy_read_timeout 3600s;       # keep alive beyond typical idle timeout
    add_header         X-Accel-Buffering "no";
    add_header         Content-Encoding  "identity";
}

Set heartbeatInterval to less than your proxy's idle timeout, not equal to it. A 5 s safety margin is sufficient. AWS ALB default idle timeout is 60 s — use heartbeatInterval: 50_000 (50 s). Cloudflare has a 100 s limit on SSE streams — consider a self-hosted or dedicated origin.

The client reconnects automatically on drop (SSE spec native behavior + exponential backoff). If a client reconnects to the same server instance and supplies a Last-Event-ID, missed events are replayed from the in-process ring buffer (default size: 1000 events). If it reconnects to a different instance (e.g. after a deploy or rolling restart), the ring buffer is cold and the client receives a stale event, triggering a full re-invalidation of all tagged queries.

4

Scope key limits

Scope keys have a maximum length. Using raw tokens or large objects as scope keys will be rejected.

Scope keys are derived from your resolveScope function via getScopeKey(). The result is used as a channel name in the event router. The default maximum is maxScopeKeyLength: 128 characters. If your scope key exceeds this, the connection is rejected with a 400 response.

Scope key configuration
// Default maxScopeKeyLength is 128 characters.
// UUIDs (36 chars) and short user IDs are always safe.
// Composite keys that exceed 128 chars are truncated/rejected.

const tagInvalidation = createTagInvalidationSystem({
  maxScopeKeyLength: 256,   // raise if your scope keys are legitimately long
})

// Prefer short, stable keys — not raw JWT tokens or full user objects.
resolveScope: (ctx) => ({ kind: "user", id: ctx.userId })  // ✅
resolveScope: (ctx) => ({ kind: "custom", key: ctx.rawJwt })  // ❌  too long

The four built-in scope kinds — global, user, tenant, custom — produce short deterministic keys (e.g. "user:abc123"). Never pass a raw JWT, serialized session object, or cookie value as a scope key. Extract the stable identifier first.

5

Serverless & ephemeral runtimes

SSE connections require a persistent process. Serverless functions (Vercel, AWS Lambda, Cloudflare Workers) cannot hold long-lived connections.

createTagInvalidationSystem() creates an in-process event router. In a serverless environment, each function invocation is a fresh process — SSE connections cannot survive across invocations, and clients would need to reconnect on every warm-up.

Redis broadcast for multi-instance / serverless-adjacent deployments
// ❌  Serverless: createTagInvalidationSystem() runs on every cold start.
// SSE connections are held in-process — they die with each function instance.

// ✅  Use a persistent process (long-running Node/Bun server) + Redis broadcast
// so SSE connections survive across instances.
import { createRedisBroadcast } from "@tanstack-tools/query-tags/redis"

const broadcast = createRedisBroadcast({
  publisher: redisPublisher,
  subscriber: redisSubscriber,
})

const tagInvalidation = createTagInvalidationSystem({ broadcast })
Supported

Long-running Node.js/Bun/Deno processes (VMs, containers, Railway, Fly.io, Render)

Requires Redis broadcast

Multiple replicas / horizontal scaling — Redis fan-out keeps all instances in sync

Not supported

True serverless (Vercel Edge Functions, AWS Lambda, Cloudflare Workers) — no persistent TCP connections

TanStack Start deployed to Vercel uses serverless by default. Pin your invalidation server to a persistent runtime (or use a separate long-running microservice), and point createHTTPInvalidatorClient at it from the frontend.

6

Parameterized tags & the path registry

Tag factories that require arguments are not included in parent-group expansion until they have been called at least once.

defineTags() collects leaf values eagerly for zero-argument factories (e.g. list: () => ["todos"]). For factories with required arguments (e.g. byId: (id) => ["todo", id]), individual instances cannot be enumerated at definition time. Instead, each invocation registers its path in the server-side path registry, which is used during group invalidation. If a resource has never been fetched, invoking the parent group (appTags()) may not match it.

Parameterized tag behavior
const appTags = defineTags({
  byId: (id: string) => ["item", id],
})

// appTags.byId() is NOT callable without args — it has required params.
// Invoking the parent group appTags() will NOT include byId variants
// in the initial leaf scan. They are matched by path AFTER first invocation.

// ✅  Works — invalidate a specific item
await tagInvalidation.invalidateTags({ tags: [appTags.byId("abc")] })

// ✅  Works after the item has been fetched once — path registry has seen it
await tagInvalidation.invalidateTags({ tags: [appTags()] })

// ⚠️  If "abc" has never been fetched, appTags() may miss it
// until the client has called appTags.byId("abc") at least once.

For "invalidate all items" semantics, prefer a zero-argument sibling tag alongside the parameterized one:

const appTags = defineTags({
  todos: {
    list:  () => ["todos"],          // zero-arg — always in group expansion
    byId:  (id: string) => ["todo", id],  // parameterized — path-matched
  },
})
7

EVERY_INVALIDATION_TAG — use sparingly

A built-in tag that fires on every invalidation event. Useful for activity indicators, but can cause excessive refetches if overused.

EVERY_INVALIDATION_TAG is automatically included in every broadcast. Any query tagged with it will refetch whenever any invalidation occurs, regardless of which tags were actually invalidated. This is intentional for cross-cutting concerns like "last activity" banners, but applying it to data-heavy queries will cause unnecessary network traffic and re-renders.

EVERY_INVALIDATION_TAG usage
import { EVERY_INVALIDATION_TAG } from "@tanstack-tools/query-tags"

// This query refetches on EVERY invalidation event, regardless of tags.
// Use sparingly — it fires even when nothing it cares about changed.
useQuery({
  queryKey: ["global-banner"],
  queryFn:  fetchBanner,
  meta: { tags: [EVERY_INVALIDATION_TAG] },
})

Never tag a query that fetches large datasets with EVERY_INVALIDATION_TAG. In high-write applications, this is effectively “poll every write event” which defeats the purpose of targeted invalidation.

Quick reference

When to use — and when not to

ScenarioVerdict
Multi-tab data sync for CRUD appsGreat fit
User-scoped collaborative editingGreat fit
Admin dashboards with moderate write volumeGood fit
Single-instance long-running serverGood fit
Horizontally scaled backends (multiple replicas)Supported with Redis
SSE behind nginx / TraefikSupported with config
True serverless (Lambda, Cloudflare Workers)Not supported
High-frequency real-time feeds (tickers, sensors)Wrong tool

Ready to proceed?

See tag invalidation in action

The interactive demos show all the patterns covered here — from basic tagged queries to scopes, reconnection, and advanced batching.