Documentation
Deployment Guide
Deploy query-tags apps to production. Covers the SSE constraint, single vs. multi-instance scaling with Redis, Docker, and reverse proxy configuration.
The Core Constraint: SSE Needs Long-Lived Connections
All apps in this library expose Server-Sent Events (SSE) for real-time tag invalidation. SSE is a long-lived HTTP response that the server writes to over time. This shapes every hosting decision.
Works on
- Long-lived Node.js or Bun processes (VPS, container, Fly.io, Render)
- Streaming-capable edge runtimes (Cloudflare Workers with streaming, Deno Deploy)
Does NOT work on
- AWS Lambda (default short timeout — connection is dropped)
- Vercel Serverless Functions (same timeout issue)
Serverless workaround
- Deploy only the SSE endpoint on a long-lived host (small VPS or Cloudflare Worker)
- REST mutation routes can stay serverless — only the /sse path needs persistence
: ping heartbeat frame every 30 seconds (configurable via heartbeatInterval). Any load-balancer idle timeout shorter than ~35 s will sever SSE connections. Set the timeout to at least 60 s, or reduce the heartbeat interval.Pick Your Path
The right setup depends on whether you need horizontal scaling and how your app is structured.
Single Instance
One Node.js process. In-process EventEmitter broadcast. No external dependencies beyond Node.
Use when: Hobby projects, low-traffic apps, or monolithic deploys where one instance is sufficient.
ScalableMulti-Instance + Redis
Multiple replicas. Redis pub/sub syncs invalidations across processes. Shared Keyv store for the path registry.
Use when: Production workloads that need horizontal scaling, blue-green deploys, or multiple regions.
DecoupledSPA + Separate Backend
Static Vite SPA served from any CDN. Backend on any Node/Bun framework using a framework adapter.
Use when: Apps that don't use TanStack Start, or existing backends you want to add invalidation to.
Single Instance
The default createTagInvalidationSystem() call uses an in-process EventEmitter. No Redis, no external store — just Node.js.
// src/lib/tag-system.server.ts
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server"
// Zero-config: in-process EventEmitter, no external dependencies
export const tagInvalidation = createTagInvalidationSystem().server.ts suffix prevents the module from being imported in client-side code. All server-only modules should use this naming convention.Multi-Instance + Redis
The default in-process broadcast only reaches clients connected to the same process. To scale horizontally, pass a BroadcastAdapter backed by Redis pub/sub and a shared Keyv store so the path registry is consistent across all instances.
// src/lib/tag-system.server.ts
import { createTagInvalidationSystem } from "@tanstack-tools/query-tags/server"
import { createRedisBroadcast } from "@tanstack-tools/query-tags/redis"
import Keyv from "keyv"
export const tagInvalidation = createTagInvalidationSystem({
// Redis pub/sub: invalidations on any instance reach all connected clients
broadcast: createRedisBroadcast({ url: process.env.REDIS_URL }),
// Shared Keyv store: path registry is consistent across instances
store: new Keyv({ uri: process.env.REDIS_URL }),
})createRedisBroadcast is imported from @tanstack-tools/query-tags/redis. Install keyv and @keyv/redis alongside the package if you need the shared store.SPA + Separate Backend
For Vite SPAs without TanStack Start, build the SPA as a static site and pair it with any Node/Bun backend using a framework adapter. The SPA connects via createHTTPInvalidatorClient from the /http-client entry.
Build the SPA
# Build the SPA frontend (Vite, no SSR)
pnpm --filter @tanstack-tools/example-spa build
# Output: apps/example-spa/dist/ — serve as a static siteUse the HTTP invalidator client
// src/lib/invalidator-client.ts
import { createHTTPInvalidatorClient } from "@tanstack-tools/query-tags/http-client"
// For SPAs: use /http-client, not /client (which requires TanStack Start)
export const invalidatorClient = createHTTPInvalidatorClient({
basePath: "/api/invalidator",
})@tanstack-tools/query-tags/http-client, not from /client. The /client entry requires TanStack Start server functions and will fail in a plain Vite app.Production API URL
The Vite dev proxy only runs during development. In production, either set VITE_API_URL at build time or configure your reverse proxy to route /api to the backend.
VITE_API_URL=https://your-backend.example.com as a CI build argument.Promoting standalone servers to production
The example-server-* apps are demo-only. Before deploying any of them, apply this checklist:
// package.json additions for production readiness
{
"scripts": {
"build": "tsup src/server.ts --format esm --out-dir dist",
"start": "node dist/server.js"
}
}// Lock CORS origin instead of wildcard
app.use(cors({ origin: "https://your-spa.example.com" }))
// Add a health endpoint
app.get("/healthz", (_req, res) => res.status(200).send("ok"))- □Add a build script (tsup or tsc) and a start script
- □Lock CORS origin — replace cors() with cors({ origin: 'https://your-spa.example.com' })
- □Swap in-memory todo store for a real database
- □Add a health endpoint: GET /healthz returning 200 OK
- □Add structured logging (pino, Winston)
- □For multi-instance: pass a BroadcastAdapter (see above)
Docker (TanStack Start Apps)
The repository includes a multi-stage Dockerfile for docs-webapp. The runner image has no node_modules — Nitro's node-server bundle includes all runtime dependencies inline. Final image is typically under 200 MB.
# Build from repo root — selects docs-webapp
docker build \
-f apps/docs-webapp/Dockerfile \
-t query-tags-docs \
.
docker run --rm \
-p 3000:3000 \
-e NODE_ENV=production \
-e PORT=3000 \
-e HOST=0.0.0.0 \
query-tags-docsEnvironment variables
| Variable | Required | Default | Notes |
|---|---|---|---|
| PORT | No | 3000 | Port Nitro listens on |
| HOST | No | 0.0.0.0 | Set explicitly in containers — Nitro may default to 127.0.0.1 |
| NODE_ENV | No | production | Set in Dockerfile |
| REDIS_URL | Multi-instance only | — | Redis connection URL for broadcast + shared store |
VITE_FEEDBACK_URL is baked into the client bundle. Pass it as a Docker build argument: docker build --build-arg VITE_FEEDBACK_URL=…Heartbeat and Idle Timeouts
The server sends a : ping SSE comment every 30 seconds to keep the connection alive. If a load balancer or proxy has a shorter idle timeout, it will sever SSE connections. Either increase the proxy timeout or reduce the heartbeat interval.
// Reduce heartbeat if your proxy idle timeout is < 35 s
export const tagInvalidation = createTagInvalidationSystem({
heartbeatInterval: 15_000, // ping every 15 s instead of the default 30 s
})Reverse Proxy Configuration
The library already sets X-Accel-Buffering: no and Content-Encoding: identity on every SSE response to signal proxies not to buffer. Most setups work out of the box — but explicit proxy configuration is recommended for production.
server {
listen 80;
server_name docs.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSE: disable buffering so events stream immediately
location /api/invalidator/sse {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_buffering off; # critical — disables response buffering
proxy_cache off;
proxy_read_timeout 86400s; # must exceed heartbeatInterval
add_header X-Accel-Buffering no;
proxy_set_header Connection "";
proxy_set_header Host $host;
gzip off;
}
}Other proxies
Caddy
Streams SSE natively. No extra config unless you have an encode directive that includes text/event-stream — remove that content type from the encode list.
AWS ALB
Set Idle Timeout to ≥ 60 s on the load balancer. ALB does not buffer or compress responses by default.
Cloudflare
Works on free/pro. On enterprise plans, verify Response Buffering is off for the SSE route.
HAProxy
Default behavior streams. Set timeout server long enough (≥ heartbeat × 2). No response buffering by default.
SSE Diagnostics
The fastest way to rule out proxy issues is to probe the SSE endpoint directly with curl from outside the proxy. You should see headers and an event: connected frame within ~1 second.
curl -N -i --max-time 5 "https://your.domain/api/invalidator/sse?scopeKey=global"
# Expected within ~1 second:
# HTTP/1.1 200 OK
# Content-Type: text/event-stream
# X-Accel-Buffering: no
# Content-Encoding: identity
#
# event: connected
# data: {"scopeKey":"global"}| Symptom | Likely cause |
|---|---|
| Request stays pending, no event: connected | Response buffering or compression buffering |
| Connects then drops after 30–60 s, repeats | Proxy idle timeout shorter than heartbeat interval |
| 502 / 504 immediately | Upstream timeout or app not running |
| Works locally but not in production | A CDN or extra hop is holding the stream |