Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.
The Problem #
Pollarise's vote counts were finally correct after adding the unique index and atomic $inc (see yesterday's post), but the results screen was still boring to watch. Someone could vote from their phone and the person staring at the results on a projector wouldn't see the tally move until they refreshed the page.
Context #
Pollarise polls run live during meetups and standups — the whole point of the results screen is watching numbers change as people vote. A stale count defeats the feature even though the underlying data is now correct.
What I Tried #
The obvious first move was client-side polling:
useEffect(() => {
const interval = setInterval(async () => {
const res = await fetch(`/api/polls/${pollId}/results`);
setResults(await res.json());
}, 3000);
return () => clearInterval(interval);
}, [pollId]);What Went Wrong #
At a 3-second interval, results still felt laggy — a vote could take up to 3 seconds to show up, which is long enough to notice on a projector. Dropping the interval to 1 second fixed the lag but multiplied the request volume: every open results tab was hitting GET /api/polls/:id/results even during the long stretches where nobody had voted at all. For a poll open across a whole room, that's dozens of tabs polling a mostly-unchanging endpoint every second.
The Solution #
Replaced polling with Server-Sent Events (SSE) — a plain HTTP response the server keeps open and writes to whenever there's actually something new, instead of the client asking on a fixed schedule:
import { EventEmitter } from "node:events";
const pollEvents = new EventEmitter();
// called from the vote handler, right after the $inc succeeds
export function publishResults(pollId: string, results: PollResults) {
pollEvents.emit(pollId, results);
}
export function streamResults(req: Request, res: Response) {
const { pollId } = req.params;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
const onResults = (results: PollResults) => {
res.write(`data: ${JSON.stringify(results)}\n\n`);
};
pollEvents.on(pollId, onResults);
req.on("close", () => {
pollEvents.off(pollId, onResults);
res.end();
});
}The client uses the browser's built-in EventSource instead of a fetch loop:
useEffect(() => {
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.onmessage = (event) => setResults(JSON.parse(event.data));
return () => source.close();
}, [pollId]);Why It Works #
SSE is a single long-lived HTTP response that the server writes newline-delimited data: ...\n\n frames into whenever it wants — there's no separate protocol, no upgrade handshake, and no polling schedule to tune. The connection just sits open and the server pushes the moment publishResults runs, so a vote shows up in milliseconds instead of on the next poll tick.
I reached for SSE instead of WebSockets because the data only flows one way: the server tells connected results screens about new tallies, and the client never sends anything back over that channel (voting is a normal POST, unrelated to the stream). WebSockets would have given me a full bidirectional channel I didn't need, plus a protocol upgrade to handle correctly through proxies and the load balancer. EventSource also reconnects automatically on drop, which I'd otherwise have had to write myself.
Lessons Learned #
The transport should match the shape of the data flow, not the other way around. A fixed-interval poll assumes updates arrive on a schedule; they don't — they arrive when someone votes, which is exactly what a push-based connection is for. And "real-time" doesn't automatically mean "reach for WebSockets" — that's the right tool when both sides need to talk, not when only one side ever has something to say.
What I Would Do Differently #
The in-memory EventEmitter only works because Pollarise still runs as a single Node process. If it ever needs a second instance behind the load balancer, a vote published on process A won't reach a results screen connected to process B — publishResults would need to go through something shared, like Redis pub/sub, instead of a local event emitter. Not worth building today at Pollarise's current traffic, but worth remembering before scaling out horizontally.
Related Concepts #
Server-Sent Events vs. WebSockets vs. long polling, the EventSource API and its automatic reconnect/Last-Event-ID behavior, fan-out with Redis pub/sub for multi-instance deployments, backpressure on slow readers.
Related content
Serving Premium Downloads Without Exposing the File's Real Storage URL
TIL how to replace a guessable direct-download link with a permission-checked, time-limited presigned URL, so the object storage path a purchased asset actually lives at is never sent to the client.
Preventing Duplicate Votes in a Polling API Under Concurrent Requests
TIL that a 'check if the user already voted, then write' guard is racy under concurrent requests — the fix was a unique compound index plus an atomic $inc, so MongoDB rejects the duplicate instead of the app trying to catch it first.
Debugging Problems That Only Appear in Production
TIL that a bug I couldn't reproduce locally turned out to depend on a real difference between environments — request concurrency — that my local setup structurally couldn't produce, no matter how hard I tried to repro it.