Killing the 2-second poll - one SSE stream for the whole workspace
The engineering behind Rezee's real-time sync engine - measured costs of polling vs server-sent events (requests, wire bytes, staleness), the anatomy of an SSE stream, the proxy and connection-limit traps, and why writes stay REST.
Jun 17, 2026 · 13 min read · Kash Gohil
Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. For its first year, every "live" surface in the app was a lie told twice a second. Chat polled. CI run status polled. Log lines polled. It was the right lie - we wrote a whole post defending it - but it was N timers on N surfaces, each paying full request overhead to usually learn nothing. The sync engine replaces them with one server-sent-events stream per workspace session: every surface's liveness rides a single HTTP response that never ends, and writes stay ordinary REST.
This post is the engineering case, with the costs measured rather than asserted. The rig (scripts/bench/sse-vs-poll in our repo) serves the same simulated chat feed over both transports to 1-200 clients and counts every request, every body byte, every measured header byte, and the staleness of every delivered event. As always: local numbers, treat them as shapes.
What did polling actually look like in the app?
Not a strawman - our real client code, from apps/pochita/src/lib/queries.ts:
export const chatMessagesQuery = (ws: string, channel: string) =>
infiniteQueryOptions({
queryKey: ["workspaces", ws, "chat", channel, "messages"],
queryFn: ({ pageParam }) =>
api.chatMessages(ws, channel, { limit: 50, before: pageParam }),
// ...
refetchInterval: 2000,
});
export const runQuery = (ws: string, repo: string, runId: string) =>
queryOptions({
queryKey: ["repos", ws, repo, "runs", runId],
queryFn: () => api.run(ws, repo, runId),
refetchInterval: (q) => {
const s = q.state.data?.status;
return s === "pending" || s === "running" ? 2000 : false;
},
});Note the discipline that was already there: run status only polls while a run is live, artifacts stop polling once found. Polling done carefully is not stupid - it's just structurally unable to be cheap and fresh at the same time, and the measurements below put numbers on that trade-off.
What does a poll cost when nothing happened?
The busy-channel scenario first: one message every 2 seconds, so every poll actually returns something - polling's best case. In a 20-second window:
Two honest readings. First, requests scale exactly as you'd predict - 10 per client per window for polling, 1 per client for SSE - and at 200 clients that's 2,000 requests against 200. Second, and this surprised us into re-checking the rig: on a busy channel, SSE's body bytes are slightly higher (403KB vs 354KB at 200 clients), because every event carries id:/event:/data: framing and every client receives every event individually, while a poll response batches. Polling's real wire cost on a busy channel is headers: we measured 266 bytes per exchange (158B request with a session cookie + 108B response headers, captured over a raw socket), and at 200 clients that's 519KB of headers to move 354KB of payload. The protocol overhead outweighs the data.
The idle channel is where polling collapses, and it's the common case - most channels, most issues, most finished pipelines are quiet most of the time. One message every 30 seconds, 60-second window:
Nearly all of polling's idle traffic is the empty-response ritual: request headers, response headers, and a {"events":[],"seq":n} body that says "still nothing," 30 times a minute per client. The SSE connections spent the same minute costing almost exactly zero - a comment-line heartbeat per connection. Polling's cost scales with time × clients; SSE's scales with events × clients. Quiet workspaces are where those curves diverge hardest.
How stale is "2 seconds," really?
Staleness - event created to event visible - is polling's other structural tax, and its distribution is exactly what theory predicts: uniform between 0 and the poll interval, because the event lands at a random point in somebody's poll cycle.
Measured p50 894ms, p99 1,971ms for the 2s poll; 6ms / 13ms for SSE at 200 clients. Two seconds sounds harmless until you compose it: a chat reply crossing two participants' poll cycles averages a full second of dead air each way, and a CI status change waits ~1s to appear after the log line that caused it was already batched. The unified shell's promise - "the UI stays ahead of the work" - has a number, and the number is single-digit milliseconds, not a poll interval.
What is an SSE stream, mechanically?
Server-sent events is barely a protocol, which is its main virtue. The response is Content-Type: text/event-stream and it simply never finishes; events are newline-delimited text frames:
id: 8412
event: chat
data: {"surface":"chat","channel":"general","seq":8412,...}
id: 8413
event: issue
data: {"surface":"issues","key":"ACME-31","status":"done",...}
: heartbeatEvery line here is load-bearing:
id:is the resume cursor. The browser'sEventSourceremembers the last id it saw and sends it back as aLast-Event-IDheader on reconnect - automatically, no client code. Our ids are the workspace event sequence (the same bigserial-seq idea chat ordering already uses), so reconnect-and-replay is one indexed query:WHERE workspace_id = $1 AND seq > $2. A dropped connection loses nothing; delivery is at-least-once with client dedupe by seq.event:routes without parsing. The client fans frames out to per-surface handlers (chat cache, issue board cache, run status) on the event name alone; a surface you don't have mounted costs a string comparison.: heartbeatkeeps intermediaries honest. A comment line every 15 seconds proves liveness through proxies and LBs that kill idle connections, and lets the client distinguish "quiet workspace" from "dead connection" cheaply.- Reconnection is built in.
EventSourceretries with backoff on its own. The failure mode of the whole system is "degrade to a reconnect loop," which is to say: it degrades to polling.
The write path is deliberately unchanged: sending a message, moving an issue, approving an agent's proposal are all still plain REST calls. SSE is strictly a notification fabric - the stream tells you that something changed and carries enough to update a cache; it is never the authority. That split is what keeps the sync engine an optimization rather than a correctness dependency.
Why SSE and not WebSocket?
Because every hard part of WebSocket buys bidirectionality, and we don't need it. Writes already have a transport with auth, validation, idempotency, and error semantics - HTTP. What chat, issues, runs, docs, and notifications need is server→client fan-out, which SSE does as HTTP: session cookies work unchanged, Caddy proxies it like any response, HTTP/2 multiplexes it, and there's no upgrade handshake, no ping/pong protocol, no reframing of auth. The roadmap keeps WebSocket in reserve for true bidirectional needs (live doc cursors, presence); adopting it today would mean maintaining a second auth path and a second delivery semantics to move data we can already move.
The honest costs on SSE's side of the ledger: it's text-only (fine - our events are JSON), and it's one-directional (the point). The one real operational trap we hit is below.
What breaks in the middle: proxies and connection limits
Two traps, both of the kind you only meet in production shapes:
- Buffering intermediaries. Anything between server and client that buffers - a proxy collecting a response before forwarding - turns a live stream into a dead one. We'd met this exact failure before in a different costume: git clone progress through denji needed a flush after every write to stream through the same Caddy. The SSE handler flushes per event frame for the same reason, and Caddy passes
text/event-streamthrough unbuffered. If you self-host anything SSE-shaped behind nginx,proxy_buffering offis the first thing to check. - The six-connection cliff. HTTP/1.1 browsers cap connections per origin at ~6. One workspace stream per tab means a user with six rezee tabs on HTTP/1.1 has zero connections left for actual requests - the app freezes in a way that looks nothing like its cause. HTTP/2 makes this vanish (streams multiplex over one connection), and Caddy terminates HTTP/2 by default, so production never sees it. But
bun run devserves HTTP/1.1, which is exactly the kind of environment-specific landmine worth documenting: the sync engine's dev-mode fallback is a sharedBroadcastChannelso N tabs share one stream.
What this replaces, and what it doesn't
The 2-second refetchIntervals for chat, issue boards, run status, docs lists, and notifications all collapse into stream-driven cache updates - the event arrives, the relevant query cache is patched or invalidated, and the UI moves. Two polls deliberately survive:
- CI log content. Log lines are bulk data with their own batching economics; the stream carries "step 3 has new output," and the log pane fetches batches exactly as before. Pushing every log line through the workspace stream would make one noisy build everyone's bandwidth problem.
- The reconnect gap. Between disconnect and resume, the client is blind; on resume it replays from its last seq. For the seconds in between, nothing polls - staleness during a network blip is bounded by the blip, which no transport fixes.
Here's the difference as an interactive side-by-side - same simulated traffic, both transports:
This is an interactive demo - it needs JavaScript. Simulated chat messages are delivered to two panes: one polling every 2 seconds (watch its request counter climb and its per-message staleness bounce around the poll interval), one on a single held stream connection where messages appear in tens of milliseconds.
How do you reproduce this?
bun scripts/bench/sse-vs-poll/server.ts &
bun scripts/bench/sse-vs-poll/run.ts # busy channel
EVENT_INTERVAL_MS=30000 bun scripts/bench/sse-vs-poll/server.ts & # idle channel
BENCH_CLIENTS=1,50,200 BENCH_WINDOW_MS=60000 BENCH_OUT=sse-vs-poll-idle \
bun scripts/bench/sse-vs-poll/run.tsHeader overhead is measured, not estimated: the driver opens a raw TCP socket, performs one real poll exchange verbatim (including a realistic session cookie), and counts bytes to the end of the response headers. Body bytes are counted server-side per transport. Staleness is Date.now() at receipt minus the event's creation timestamp, same clock, same process - which is also this benchmark's biggest flattery, addressed below.
What doesn't this prove?
- Localhost hides latency and TLS. Real networks add RTT to every poll (making staleness worse than measured) and TLS record overhead to every exchange (making polling's header tax worse). Both flatter polling here, not SSE.
- The header measurement is one shape. 266 bytes assumes one modest cookie and no extra headers; real browsers send more (user-agent, accept-language, sec-*), so treat 266B/exchange as a floor.
- Same-process clocks. Staleness numbers avoid clock-skew problems by being one process; they measure transport delay, not end-to-end UX (rendering, cache patching) - those costs are identical for both transports.
- 200 connections is not 20,000. Bun holds a few hundred open SSE responses without visible strain, but this rig says nothing about the fan-out architecture at real scale - per-workspace event buses, backpressure on slow clients, and the memory cost of open response objects are the sync engine's actual hard problems, and they deserve their own post once they've been in production long enough to be measured honestly.
FAQ
Why did Rezee poll for a year if SSE is this much better?
Because polling was the correct first system: one mechanism, no connection lifecycle, no proxy traps, degradation-free, and we knew exactly what it cost - a couple of seconds of staleness nobody complained about at small scale. The sync engine became worth its complexity when the surfaces multiplied: five surfaces × N tabs × 2-second timers is a tax that compounds, and the fix is one stream, not five faster polls.
Does one stream per workspace session actually scale?
The connection count is bounded by concurrent sessions, not surfaces - that's the whole trick; opening the issues board doesn't add a connection, it adds a handler on the existing stream. Fan-out is an in-memory per-workspace subscriber set in makima, the same shape as the bench server's subscriber loop. The unsolved-at-scale parts (slow-client backpressure, multi-node fan-out) are listed honestly above.
What happens to events sent while a client is disconnected?
They're in Postgres, not in the stream. The stream is notification, the database is truth: on reconnect, EventSource presents Last-Event-ID, the server replays everything after that seq from the workspace event log, and the client dedupes by seq. Missing the stream entirely (laptop lid closed for an hour) degrades to what the app did before the sync engine existed: fetch fresh state.