Skip to content
qt

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
The server sends a : 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

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()
The .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 site

Use 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",
})
Import from @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_* variables are baked in at build time — they cannot be changed without a rebuild. Pass 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.jsonJSON
// 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-docs

Environment variables

VariableRequiredDefaultNotes
PORTNo3000Port Nitro listens on
HOSTNo0.0.0.0Set explicitly in containers — Nitro may default to 127.0.0.1
NODE_ENVNoproductionSet in Dockerfile
REDIS_URLMulti-instance onlyRedis connection URL for broadcast + shared store
VITE_* variables are build-time only. 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.

Running on Coolify, Dokploy, Portainer, or another platform that auto-manages Traefik? See SSE Diagnostics below — the library's headers usually handle it, but if not, the Traefik labels above apply via each platform's "Custom Labels" UI.

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"}
SymptomLikely cause
Request stays pending, no event: connectedResponse buffering or compression buffering
Connects then drops after 30–60 s, repeatsProxy idle timeout shorter than heartbeat interval
502 / 504 immediatelyUpstream timeout or app not running
Works locally but not in productionA CDN or extra hop is holding the stream

FAQ

Not directly for the SSE endpoint — serverless functions have short timeouts that sever the long-lived SSE connection. The workaround is to deploy only the SSE endpoint on a long-lived host (a small VPS, Fly.io, or a Cloudflare Worker with streaming enabled) while REST mutation routes stay serverless.