GenerateSaaS

Live Updates

Open web and desktop pages re-read what changed elsewhere - another tab, the chat, an agent on the MCP server, a background job - over one coalesced signal.

A change made anywhere shows up within seconds on every open page it affects, on the web and in the desktop app, without a reload. Only the NAME of what changed (a data domain) travels; each page re-reads its own data through its usual request.

How a change travels

StepWhereWhat happens
Publishpackages/api/src/live/publish.tsAfter a successful write: every AI capability that is not readOnlyHint (chat, desktop CLI tools, MCP), every mutating request on a /projects path, automation runs and edits, AI settings, BYOK keys, integration changes.
AuthorizesameWorkspace-shared data (automations, integrations, the project list, tool calls) goes to its OWNER - the organization in a multi-tenant build, else the user; a /projects/<id>/<area> route goes to that project. A project or organization is published to only after the acting user resolves as a member. Publishing never holds up the response.
Coalesce@repo/runtime publishLiveChangeOne cacheEval script bumps the scope's version hash and, behind a SET NX PX gate, publishes at most one message now and one trailing per scope and field every 2 s. A burst of 200 writes is two messages.
Fan outGET /live/streamOne Redis subscriber per process, fanned out in memory to one server-sent-event stream per visible tab or window.
Refresh@repo/app-core/lib/live-updatesThe client batches changes for 500 ms (refreshing at most once per 2 s), maps them onto WORKSPACE_DATA_DOMAINS, pings every useDataRefresh listener and invalidates the matching React Query reads (only mounted ones refetch).

Scopes are the signed-in user, the active organization and the active project. A tab's own writes are left out of its stream on the server (x-live-origin, sent on writes and on the stream, never on a read and never sent back), because it already refreshed from its own result. Each message carries the field's version, so a reconnect's catch-up read never refreshes the same change twice.

Transport and cost rules

  • Stream where Redis can subscribe: a heartbeat every 25 s; session, ban and membership re-checked every 2 heartbeats; the stream ends after 10 minutes and the client reconnects through the guard. Session cookies only: an API key is refused on both routes.
  • Poll of GET /live/version every 30 s where it cannot: the Upstash build (redisSupportsSubscribe is false), liveUpdates.stream: false, a 501, or three failed connects. After failed connects the client tries the stream again about a minute later once the poll says the server streams (a deploy restart, a full cap).
  • Hidden tabs cost nothing: changes wait until the page shows, the stream closes after a minute hidden, the poll stops.
  • Reconnects wait at least 1 s plus jitter, backing off while streams keep failing. A stream silent for 60 s (more than two heartbeats: a sleeping laptop, a dropped NAT mapping) is dropped and opened again.
LIVE_STREAMS_PER_USER (8) is counted per process, and each process holds one Redis subscriber: N instances allow N x 8 streams per user. A separate backend needs nothing extra (X-Live-Origin is in the CORS allow-list); no new environment variables.
On a serverless host (Vercel, Netlify), each open stream holds a function instance for up to 10 minutes, and the per-process cap and single subscriber no longer bound anything. Serverless builds use the Upstash cache, which already polls; if yours streams, set liveUpdates: { enabled: true, stream: false } to poll, or turn live updates off.

Making a screen live

Listen to a domain. A React Query read under queryKeys is already covered when its area is listed in LIVE_DOMAIN_QUERY_AREAS:

import { useDataRefresh } from "@repo/app-core/hooks/use-workspace-refresh";

useDataRefresh("automations", () => void refresh());

Add your own domain in three places:

Add it to WORKSPACE_DATA_DOMAINS and its query areas to LIVE_DOMAIN_QUERY_AREAS (packages/app-core/src/lib).
Map each mutating capability to it in MUTATING_TOOL_DOMAINS, so chat and MCP writes refresh it.
Serve its routes under /projects/:projectId/<domain>/… (published automatically), or call announceWorkspaceChange (a user's write) or announceRowChange (a job's write) after the change lands.

A mutating route that changed nothing (a quote, a preview) sets the x-live-unchanged response header; the middleware honours and strips it.

Advice derived from state

Anything a screen derives from a state - a reconnect prompt, a plan step, a low-credits notice - is computed when it is READ, never stored when the state was last written. A stored copy goes stale the moment the state changes some other way (a reconnect, a top-up), and no publish can fix a row nothing recomputes. When a health check passes, restart whatever that connection feeds through the same single re-sync path every caller uses.

Turning it off

packages/config/src/index.ts
liveUpdates: { enabled: false },

Nothing is published, /live/* answers 404 and no client mounts. Screens still refresh after this tab's own chat turns.

On this page