Realtime / WebSocket Subsystem

Single /ws endpoint with three scoping modes (per-connection, per-room, per-user), Redis pub/sub for multi-instance delivery, four-layer reliability (server ping, client watchdog, sweeper, reconnect with jitter), and the task reconciler that cleans zombie state when processes die mid-stream.

One /ws endpoint carries all realtime traffic

/ws is the runtime infrastructure behind chat conversations, task streaming, and task lifecycle notifications — one WebSocket per browser tab, and all realtime traffic rides it. The hard part isn’t “send a message”; it’s three: where a message should route (broadcast to a room? push to every tab of one user? reply to one connection?), how a connection stays alive through network turbulence, and how the zombie task state a dead process leaves behind gets cleaned up on its own. This page works through all three.

One distinction first: this is the runtime substrate for realtime transport; the content protocol flowing over it (data parts, AI SDK chunks) is a separate concern — see Streaming Architecture.

Single endpoint, three scoping modes

All real-time traffic flows through one WebSocket per browser tab. Inside the server, the wsHub singleton tracks each connection and routes messages by one of three scoping modes:

sequenceDiagram participant C1 as Client tab A · userX participant C2 as Client tab B · userX participant C3 as Client tab C · userY participant Hub as wsHub participant Redis Note over C1,Redis: ① Three scoping modes share one connection C1->>Hub: send_message Hub->>Hub: send(connectionId, error) Note right of Hub: per-connection — point-to-point reply Hub->>Redis: publish room channel Redis-->>Hub: deliver Hub-->>C1: chat:stream:frame Hub-->>C3: chat:stream:frame Note right of Hub: per-room — explicit join, multi-subscriber Hub->>Redis: publish user channel Redis-->>Hub: deliver Hub-->>C1: task:event Hub-->>C2: task:event Note right of Hub: per-user — automatic, all tabs of one user
ModeAPIUsed by
Per-connectionwsHub.send(connectionId, msg)HITL replies, error responses, task stream frames addressed to the caller
Per-room (explicit join)wsHub.broadcast(roomId, msg) + join/leaveChat conversations: multiple participants subscribe to a conversationId
Per-user (implicit)wsHub.sendToUser(userId, msg)Task lifecycle events: any tab the user has open receives them, no subscribe

The per-user mode is the foundation of the notification system. When a task completes on instance A and the user has two tabs open against instance B, both tabs see the event because the userId index is maintained on every instance and backed by Redis pub/sub.

Connection lifecycle

sequenceDiagram participant C as Client participant W as /ws route participant Auth as verifyJwt (jose) participant Hub as wsHub participant T as Heartbeat-Sweeper Note over C,T: ① Establish C->>W: WebSocket upgrade · ?token=JWT W->>Auth: verifyJwt(Bearer token) Auth-->>W: AuthUser or null Note right of W: unauthorized → close 4001 (terminal)<br/>authorized → register W->>Hub: register(connectionId, ws, userId) Hub->>Hub: maintain userToConnections index Hub-->>C: open · status connected rect rgba(52, 211, 153, 0.18) Note over C,T: ② Steady-state heartbeat (every 25 s) T->>C: ping (JSON, app-level) C->>W: pong W->>Hub: touch(connectionId) · refresh lastActivityAt end Note over C,T: ③ Detection — three independent paths Hub->>C: graceful close (1000 / 1006 / 1011 / 1008 / 4002) C->>C: watchdog · lastMessageAt exceeds 35 s · proactive close T->>Hub: sweeper · lastActivityAt exceeds 90 s Hub->>C: close 4002 (stale) Note over C,T: ④ Reconnect strategy C->>C: inspect close code Note right of C: terminal (1000 / 1008 / 4001) → stop reconnecting<br/>transient (1006 / 1011 / 4002 / others) → backoff with jitter C->>W: reconnect attempt (1, 2, 4, 8, 16, 32 s caps · full jitter) Note over C: visibilitychange / online events bypass backoff

Close codes

CodeMeaningClient behavior
1000Normal closureStop reconnecting (user-initiated disconnect)
1006Abnormal closureReconnect with backoff (most common)
1008Policy violationStop reconnecting (protocol error)
1011Internal server errorReconnect with backoff
4001Unauthorized (custom)Stop reconnecting · upstream auth flow handles redirect
4002Stale connection (custom)Reconnect with backoff (sweeper closed us)

Why server-driven heartbeat

Application-level {type: "ping"} from server every 25 s. Client responds with {type: "pong"}. Both sides track lastActivityAt. This pattern matches Socket.IO and SignalR defaults. Server-driven beats client-driven because:

  1. Tunable from one place without redeploying clients
  2. A server ping arriving proves the server itself is alive — client-driven only proves the client is alive
  3. Centralized rate-limit and abuse control

A complementary RFC 6455 native ping at the transport layer can be added if the WS adapter exposes it; it covers cases where intermediaries strip application frames but preserve protocol control frames. Currently the JSON-level ping is the sole heartbeat.

Browser event triggers

Two window events bypass exponential backoff and trigger an immediate reconnect probe:

  • visibilitychange (visible) — laptop wake / tab return
  • online — network recovery / VPN reconnect

These detect “network came back” faster than the watchdog timeout, especially after a long sleep where the OS hasn’t yet noticed the disconnect.

Multi-instance via Redis pub/sub

Each server process owns the connections that happen to land on it. To deliver a task:event to all of a user’s tabs when those tabs are spread across instances, every send goes through Redis pub/sub:

Redis pub/sub: cross-instance delivery Both instances subscribe to the user channel · publisher's own subscriber receives fanout too Instance A userToConnections userX → conn-A1, conn-A2 subscriber on ws:user:userX Redis pub/sub channel zapvol:ws:user:userX delivers to all subscribers Instance B userToConnections userX → conn-B1 subscriber on ws:user:userX ① publish ② fanout ② fanout (publisher's own subscriber too) ③ handler · localSendToUser → conn-A1, conn-A2 ③ handler · localSendToUser → conn-B1

Subscriptions are lazy: each instance subscribes to zapvol:ws:user:{userId} only while it has at least one connection for that user. Last connection drops, instance unsubscribes. This keeps the subscription set proportional to active users on that instance, not the total user base.

When Redis is unavailable (local dev), sendToUser falls back to local-only delivery within the single process. Single-process dev works; multi-instance prod requires Redis.

The same pattern is reused for room channels (zapvol:ws:room:{roomId}), chosen by wsHub.broadcast.

Reliability layers

Four mechanisms collaborate to keep the system honest:

Reliability: 4 layers of defense Each layer catches a different failure mode · server-driven (indigo) and client-driven (teal) alternate 1 · Server-driven heartbeat (every 25 s) ping/pong tracks lastActivityAt · catches client-side TCP half-dead from the server's side 2 · Client watchdog (35 s timeout) lastMessageAt exceeds 35 s · proactive close · catches server-side TCP half-dead from the client's side 3 · Server sweeper (90 s threshold) scan every 30 s · final cleanup if heartbeat path is stripped by proxy or layer 1 fails silently 4 · Reconnect with backoff + browser events transient codes → jitter backoff · terminal codes (1000 / 1008 / 4001) → stop · visibilitychange / online bypass timer

Each layer is cheap and independent — they don’t replace each other, they cover different failure modes. The full mapping from failure mode to which layer catches it:

Failure modeFirst detectionBackstop
TCP half-dead (server)server sweeper 90 s
TCP half-dead (client)client watchdog 35 sonclose
Network swap / NAT rebindonline eventwatchdog 35 s
Laptop wakevisibilitychangewatchdog 35 s
Server restartonclose 1006backoff
Auth expiredclose 4001stop reconnect
Heartbeat path strippedsweeper 90 s

A real production WS implementation needs every row.

Task reconciler — cleaning zombie state

Independent of WS, but related to the same multi-instance problem: when a server process dies mid-stream, the task it was executing has isActive = true in the database, but no compute is running. Without intervention, the sidebar shows that task spinning forever.

sequenceDiagram participant A as Instance A participant B as Instance B participant Redis participant DB as Postgres A->>DB: task X · isActive=true A->>Redis: SET task-lock:X · TTL 90 s loop every 30 s while task X runs A->>Redis: SET XX task-lock:X · TTL 90 s end Note over A: process killed (SIGKILL · OOM · rolling deploy) Note over Redis: heartbeat stops · TTL expires within 90 s loop every 2 min on every instance B->>DB: listActive() DB-->>B: rows including X B->>Redis: EXISTS task-lock:X Redis-->>B: 0 (lock gone) B->>DB: update X · isActive=false B->>Redis: publish zapvol:ws:user:userX (errored event) end

The reconciler runs on every instance. Operations are idempotent so concurrent runs are safe — no distributed lock needed for the reconciler itself. Maximum zombie-to-cleaned latency is lock TTL (90 s) + sweep interval (2 min) = ~3.5 min after process death.

When Redis is unavailable, isTaskLocked returns false. The reconciler falls back to a timestamp check: updatedAt < now - 5 min indicates a zombie. Single-instance dev mode works correctly; this fallback is only ever exercised when Redis is gone.

The reconciler is also the recovery path for the server-restart user experience: zombies become kind: "errored" events, which surface as a red toast (and optional desktop notification) telling the user the task didn’t survive a deploy.

Protocol shape

Every message has a top-level type discriminator using {domain}:{subkind} namespace. Some examples:

chat:stream:frame    chat:stream:end    chat:stream:error
task:stream:frame    task:stream:end    task:stream:error
task:event           agent:state        message:new          message:updated
typing               presence           ping                 error

task:event is the canonical task lifecycle notification:

interface WsTaskEvent {
  type: "task:event";
  taskId: string;
  kind: "created" | "completed" | "hitl" | "aborted" | "errored";
  finishReason?: string;
  errorMessage?: string;
}

Client receives → invalidates the task list → toast / sidebar badge / desktop notification fire based on kind. No client-side prev/current diff needed; the event itself is authoritative.

Future-proofing

When new domains need notifications (schedule fires, credit warnings, etc.), add new typed interfaces under the same naming convention:

interface WsScheduleEvent {
  type: "schedule:event";
  scheduleId: string;
  kind: "fired" | "failed" | "missed";
  runId?: string;
}

interface WsCreditEvent {
  type: "credit:event";
  kind: "warning" | "exhausted";
  remaining: number;
}

Each domain stays typed. The WsServerMessage union grows linearly. Client-side discriminator switches stay exhaustive under TypeScript’s narrowing.

This is intentionally not a generic {type: "notification", domain, event, payload} envelope — type erasure trades short-term simplicity for long-term cost. Multi-domain code without compile-time discriminators ages badly.

What this subsystem does NOT do

  • No guaranteed delivery / replay — events sent while a tab is disconnected are lost. The client compensates by invalidating the task list on every disconnect-to-connect transition, which authoritatively re-fetches state. For task lifecycle this is correct; for finer event streams (e.g., per-keystroke cursor positions) you would need a sequence-number based replay protocol.
  • No client-to-server task creation — task creation goes through the HTTP POST /api/tasks route. WS is for notifications and bidirectional streams that already have an HTTP-initiated endpoint.
  • No WS-side rate limiting — application-level. Server processes are protected by Redis pub/sub fanout limits and the OS file-descriptor cap; aggressive abuse handling is out of scope.
  • No protocol versioning — messages are JSON with discriminated type. Forward compatibility comes from clients ignoring unknown types. Hard breaking changes (rename a field, drop a kind) are not currently versioned; they require a coordinated client deploy.

Key parameters

ItemValueWhy
Endpoint/wsSingle endpoint for all real-time traffic
AuthJWT via ?token= query paramBrowsers can’t set headers on WS upgrade, so the token rides the query string → synthesized Authorization: BearerverifyJwt (jose)
Server ping interval25 sAligned with Socket.IO / SignalR defaults
Client watchdog timeout35 s> ping interval + one round-trip; triggers proactive reconnect
Connection sweeper interval30 sServer-side stale-connection scan
Stale-connection threshold90 sThree missed heartbeats; double sweeper interval
Task lock TTL90 sSame threshold as connection sweeper, lock-as-truth uniformity
Lock heartbeat interval30 sOne-third of TTL; survives one missed Redis call
Reconciler initial delay60 sLets co-starting instances finish boot before sweeping
Reconciler interval2 minWorst-case zombie-to-cleaned latency: ~3.5 min after process death
Client reconnect base delay1 sExponential backoff capped at 32 s, full jitter
Max reconnect attempts10Total wait ~17 min before giving up

Implementation map

ConcernFile
Connection lifecycleapps/server/src/routes/ws.ts
Hub state + routingapps/server/src/lib/ws-hub.ts
Heartbeat / sweeperSame — pingTimer, sweeperTimer in ensureSweeper()
Lock + heartbeatapps/server/src/lib/task-lock.ts
Reconcilerapps/server/src/services/task-reconciliation.ts
Boot wiringapps/server/src/index.ts
Client reconnectpackages/app/src/hooks/use-websocket.ts
Notification listenerpackages/app/src/components/app-notification-listener.tsx
Browser nativepackages/app/src/hooks/use-browser-notification.ts
Preferencespackages/app/src/stores/preferences-store.ts
Wire typespackages/common/src/types/ws.ts

Further reading

  • Streaming Architecture — what flows through the WS, the data part protocol, resumable SSE
  • Task Orchestration — how a task goes from POST to completed; lock acquisition + heartbeat is part of this lifecycle
  • Production Deployment — Redis configuration, environment variables, rolling deploy mechanics
Was this page helpful?