The Removed Member Whose Socket Kept Listening
Comparison tables stop at direction and reconnects. The work in my realtime board was auth: who may hold a socket open, and what happens to it once they may not.

You remove someone from a project. The server checks their session on every request now, so they are out. Except their browser opened a WebSocket an hour ago, and that socket never asks again. Every task move still reaches them.
That gap is where the SSE vs WebSockets question got real for me. Not direction, not binary frames. Who may hold a connection open, and what happens to it once they may not.
I built TeamBoard, a multi-user task board, as a public full-stack showcase (source on GitHub). It ships both transports plus polling. My answer, short: SSE when you run a long-lived Node server and only push one way, a WebSocket when the platform makes idle connections cheap, polling as the floor, and the server, not the browser, picks which one.
SSE vs WebSockets, the short version
Server-Sent Events are a long-lived HTTP response with the text/event-stream content type. The server writes lines, the browser's EventSource reads them. One direction only. A WebSocket is an upgraded connection that both sides can write to.
Most comparison pages stop at a table like this one, so I'll keep mine short.
| SSE | WebSocket | |
|---|---|---|
| Direction | Server to client | Both ways |
| Reconnect | Built into EventSource | You write it |
| Auth | Normal cookies, same as any request | Cookies on the upgrade; no custom headers from the browser |
| Proxies | Plain HTTP, but idle streams get cut | Needs upgrade support end to end |
| Hosting | Holds a request open on your server | Depends on the platform; see below |
Direction and reconnect differ, but the hosting row is what decided the transport in this app.
The last row decided it for me. Server-sent events vs WebSockets is a hosting question as much as a protocol one, because serverless platforms bill and recycle long-lived connections differently.
The server picks the transport, not the browser
Before writing this, I opened the live demo in two separate browser sessions, signed in as two different members. Both realtime badges read "Live", and each browser held a WebSocket to /realtime/<projectId>. A task created in one window showed up in the other about 1.3 seconds later, and a move made in the second window reached the first just as fast. Over a 25-second idle stretch neither board fetched its tasks, so nothing was polling.
So I checked what the server had told the browser. Before a board opens a live channel, it asks GET /api/projects/<id>/realtime, which checks membership first and returns one of three shapes:
{ "transport": "websocket", "url": "/realtime/<projectId>?ticket=<signed>" }
{ "transport": "sse", "url": "/api/projects/<projectId>/events" }
{ "transport": "poll", "intervalMs": 10000 }
The server decides the transport; the client never guesses.
The hook opens a socket only for the websocket shape, so that is what the demo answered. The Cloudflare runtime code shows the condition: on Workers the answer is a WebSocket into a Durable Object only when the ticket signing key is configured, and without it the server answers poll. Locally, on Node, the answer is SSE.
That condition is not hypothetical. An earlier check of the same demo, before the key was set, got poll back: both badges read "Polling" and changes took eight to nine seconds to cross. Nothing broke then either. Same hook, same board, a slower floor. The room logic has unit tests and the browser end-to-end test runs over SSE; the live check covers the socket path, not revocation. A browser that picked its own transport would have tried a socket the server could not serve; a server that answers with a descriptor can always fall back.
Server Sent Events vs WebSockets: the local stream
The local route is a plain ReadableStream. Trimmed:
const HEARTBEAT_MS = 25_000; // keeps idle proxies from closing the stream
// Subscribe before answering, so a membership failure is a
// normal error response, not a 200.
const unsubscribe = await realtime.subscribe(user, projectId, (event) =>
send(`data: ${JSON.stringify(event)}\n\n`),
);
// ...then start the stream: ": connected", a ": ping" every 25 s,
// unsubscribe on req.signal abort.
A stream that has already said 200 can't say 403 any more.
The ordering matters. If the route returned the stream first and checked membership inside it, a non-member would get a successful response that silently never delivers. Checking first keeps the failure in the status code, where the client already handles it.
Revocation is weaker on this path, and I left it that way on purpose. Every SSE reconnect is a fresh request, so it is authorized again, but an already open stream is not closed when a member is removed. SSE is the local development path only. I never ran it in production, so I make no claim about it there.
Cloudflare Durable Objects as the WebSocket room
On Cloudflare, each project gets one Durable Object, addressed by name with getByName(projectId). It accepts sockets with the Hibernation API, ctx.acceptWebSocket(server, [userId]), and registers setWebSocketAutoResponse so the runtime answers the client's ping with pong without waking the object.
A hibernated room accrues no duration charges; request and storage charges still apply. That is Cloudflare's documented behaviour for hibernating WebSockets, not a bill I measured, and this room does store ticket ids and revocation cutoffs. I have no load or cost numbers for this app.
This is why, on Workers, I chose the WebSocket and never moved SSE there. A held SSE response is an open request for its whole life. A hibernated socket, per those docs, does not keep the object in memory between messages.
The room logic lives in ProjectRoomCore, a plain class with no cloudflare:workers import, so it runs in ordinary unit tests. Same idea as the ports in clean architecture with AI agents: the platform is an adapter.
WebSocket auth: the socket outlives the check that opened it
A browser can't set an Authorization header on a WebSocket. So the realtime endpoint mints a ticket after the normal session and membership checks: HMAC-SHA-256 over projectId, userId, jti, iat, exp, valid for 30 seconds. The Worker verifies the signature and refuses anything without Upgrade: websocket (426).
The first version stopped there. Then I walked through the removal case from the opening, and it took three fixes.
Close the socket, and use each ticket once. Removing a member or logging out now calls revoke on the project's room. It records a cutoff time and closes that user's tagged sockets with code 4001 "Access revoked". The room also stores each ticket id and refuses a second use.
Refuse tickets issued before the revocation. Closing open sockets isn't enough if the user holds a ticket minted a few seconds earlier. It is still validly signed and unexpired. The room now refuses any ticket whose iat is at or before the cutoff.
Stamp before checking, and allow skew. The ticket's time was taken after the membership check. A removal landing between the two produced a ticket stamped after the cutoff, from a check that passed before it.
Now the time is taken first. The cutoff comes from a different clock, so the room adds 5 seconds of slack: iat <= cutoff + 5_000 is refused.
| Step | Where | Refused with |
|---|---|---|
| Bad signature, wrong project, expired | Worker | 401 |
| Ticket id already used | Durable Object | 401 |
iat at or before cutoff + 5 s | Durable Object | 401 |
| Already open when removed | Durable Object | close 4001 |
Four ways in, four ways refused; only the last one needs the socket to exist.
All three fixes landed the same day as the feature, each with new or changed unit tests on ProjectRoomCore. A socket is a cached authorization decision, and caches need invalidation.
WebSocket vs SSE: one hook, with polling
The browser side is one React hook with three states: connecting, live, polling. These constants are the whole policy:
const MAX_FAILURES = 5; // fifth failure in a row: poll
const BASE_BACKOFF_MS = 1_000; // waits 2 s, 4 s, 8 s, 15 s
const MAX_BACKOFF_MS = 15_000;
const SOCKET_PING_MS = 30_000; // answered by the Durable Object runtime
const FALLBACK_POLL_MS = 10_000;
The reconnect policy fits in five lines and is the same for both transports.
The counter goes up before the delay is computed, so four retries wait 2, 4, 8 and 15 seconds, and the fifth consecutive failure switches to polling. A connection that opens resets the count.
Before each retry, it asks for a fresh descriptor instead of reusing the old URL. That matters for revocation: a closed socket leads to a new descriptor request, which hits the membership check and fails. EventSource would reconnect on its own, but I close it and use the same backoff, because one policy is easier to reason about than two.
The end-to-end test opens two browser contexts as two members, waits for both badges to read "Live", then creates a task in one and moves it in the other. Locally that runs over SSE. The same hook would use the Durable Object WebSocket on Cloudflare, and its room logic is covered by unit tests, not that e2e.
Events carry no task data
After the revocation fixes, the obvious next question was what a socket could have shown in the seconds before it was closed. So I went through what the room actually pushes. Every event is { action, taskId, actorId, at }. No title, no description, no assignee. The hook reads only actorId, to skip its own mutations, and otherwise invalidates two cached query families. React Query refetches whatever is on screen through the normal API, which checks permissions again.
I kept it that way on purpose. If events carried task data, every transport would need its own copy of the access rules. With these events, task content always comes from an authorized request. They are not empty, though: a stale listener could still learn that a given user moved a given task at a given time. Content is protected by the refetch; activity metadata is not.
The trade is smaller pushes for refetches, and how many requests a change costs depends on which queries are active. For a task board that is fine. For a high-rate feed it would not be. It is the same kind of running cost I wrote about in what maintaining an app really costs.
When to pick SSE, a WebSocket, or polling
The rule I actually followed:
- SSE when updates only flow server to client and you control a long-running Node process. It is the least code and reuses your cookie auth.
- A WebSocket when the platform makes idle connections cheap, like a hibernating Durable Object, or when the client really needs to send.
- Polling always, as the floor. It needs nothing from proxies or the platform, and my live demo ran on it until the signing key was set.
Whichever you pick, treat the open connection as a permission you have to be able to take back.
Keep reading
- Twelve Apps, Nine Kits, and a Theme Function Still Called ResizePingThemeI build a family of small branded apps. Some of the code is shared through kits, some of it was copied from one app into the next. Only one of those paths lets a fix reach every app.
- Flutter vs React Native: How I Choose, and When I Go NativeA decision guide, not a benchmark: one production Flutter app, React Native work that was brownfield, SDK and sample code, and the cases where I would not use either.
- SwiftUI vs UIKit: Counts From Eight of My iOS AppsNo verdict, only what the files say: one tally per framework, the UIKit types that still appear, and where the counts stop being useful.