Engineering
Server-Sent Events with Redis: Streaming Test Results
Abhishek Bahukhandi

Every code submission in a Taqari interview runs against a batch of hidden test cases while the candidate sits there watching. Our first version polled a status endpoint once a second. It worked, and it was still wrong. This is how we replaced it with Server-Sent Events with Redis behind them — how run state is keyed, what happens when a tab reconnects mid-run, and how the stream gets closed without leaking anything.
The interesting part is not "use SSE instead of polling". It is everything you have to decide once you do: what the unit of state is, who owns the truth, and which of the three actors — browser, API, runner pool — is allowed to end the conversation.
Why polling was wrong for a judge queue
A submission is not one job. It is N test cases fanned out to a pool of runners, finishing at unpredictable times and in no particular order. A trivial case returns in a couple of hundred milliseconds. A heavy one, or one that hits the time limit, takes seconds. The user-visible event we care about is not "the run finished" — it is "case 4 just went green", twelve times in a row.
Polling flattens all of that into a snapshot. Every tick you ask "what is the state now?", and the answer arrives as a whole array rather than a change. You can diff it client-side, but you have already lost the timing, which is the thing that makes a results panel feel alive.
The latency math nobody does
Pick a one-second interval. If a transition happens at a uniformly random point inside the interval, the average delay before the UI shows it is half the interval — 500 ms. Twelve test cases means twelve transitions, each arriving on average half a second late, some of them collapsing into the same tick and appearing simultaneously when they were actually a beat apart.
You can shorten the interval, which is where the second problem starts.
The cost is paid when nothing happens
The overwhelming majority of polls during a run return "still running". That is a request, a round trip, an auth check and a state read per candidate per tick, to learn nothing. With several candidates mid-interview the traffic is dominated by requests whose correct response is "no change".
So the interval is a knob with no good setting: short enough to feel live is expensive and mostly wasted, long enough to be cheap feels dead. That asymmetry — rare, unpredictable, server-originated events — is exactly the shape Server-Sent Events were designed for. We made the same trade in the other direction for the voice side of the product, where the traffic is bidirectional and constant; our notes on that are in scaling real-time apps with WebSocket infrastructure.
The rule we landed on
If the client has nothing to say, do not make it ask. One long-lived HTTP response with the server writing into it beats a request loop whose interval you will never get right — and the browser's EventSource gives you reconnection for free.
The shape of a run: submit once, stream the rest
The submit call does not return results. It returns a handle, immediately, and the results arrive on a different connection.
const res = await fetch(API_BASE_URL + '/compiler/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sourceCode, language, sessionId, questionIndex }),
});
const data = await res.json();
// { success: true, runId, totalTestCases, sseUrl }
dispatch(setDsaCompilerRunId(data.runId));
That one dispatch is the entire trigger. A hook watches the run ID in the store and opens the stream when it appears:
const { submitCode } = useDSACompilerService();
useDSASSEStream(dsaCompilerRunId); // no-ops while runId is null
Why the submit response returns a run ID, not results
Three fields come back, and each one exists for a reason.
runId
An opaque, server-generated identifier for the whole batch. It is the only thing the client needs to keep. Every other piece of state — status, per-case results, counters — lives on the server under this key, which is what makes a page refresh survivable.
totalTestCases
The client needs the denominator before any result exists, so it can render twelve pending rows and a 0/12 progress bar the instant you hit Run. Without it the panel pops from empty to full and the run feels slower than it is.
sseUrl
The server also hands back where to listen. Our client still builds the stream URL from a convention and ignores this field, which is a small debt: the day the stream moves behind a different host, the server-supplied URL means a config change instead of a frontend release. Return it before you need it — it costs one line.
Server-Sent Events with Redis: how run state is keyed
Everything hangs off the run ID. Not the user, not the interview session, not the question — the run. That choice does more work than it looks like it does.
One key per run, not per session
A candidate can have the same interview open in two tabs, submit, and then reload one of them. If state were keyed by session you would immediately be reasoning about which tab owns which run. Keyed by run, both tabs are just subscribers to the same immutable-ish record, and neither of them owns anything.
It also makes cleanup boring, which is the highest compliment you can pay a cache key. A run record carries a TTL. Nothing has to sweep it, and nothing accumulates per user.
The record and the channel do different jobs
Two Redis structures, deliberately not one:
- The run record — status, total cases, finished count, and the per-case results as they land. This is the truth. Anyone arriving late reads it and knows exactly where the run is.
- The per-run channel — a pub/sub channel the runners publish a delta to as each case completes. This is a notification, not a store.
Pub/sub is fire-and-forget: a message published while nobody is subscribed is simply gone. That is fine, because the channel is never the system of record. If you skip the record and treat the channel as state, a reload during a run shows an empty panel forever, and you will not reproduce it locally where reloads are fast.
Why the first frame carries the counters
The stream handler does not start by waiting for the next publish. On connect it reads the record and writes a connected frame containing the current totalCases and finishedCases. A subscriber that joined at case 7 of 12 is immediately correct, and a subscriber that joined before case 1 gets zeroes. Same code path, no special case for reconnects.
Three message types, and why that is enough
The browser side is a single onmessage handler with a switch over a type field. Three values cover the whole lifecycle.
connected — take the shape of the run
Sets status to running, seeds the progress counters, and builds the pending rows:
const initialResults = Array(message.totalCases).fill(null).map((_, index) => ({
caseIndex: index,
status: 'pending',
statusId: null,
stdout: null,
stderr: null,
compile_output: null,
time: null,
memory: null,
}));
dispatch(setDsaCompilerTestResults(initialResults));
case_update — one case, by index
Every result frame carries its own caseIndex, so results are addressed rather than appended. Runners finish out of order and the UI does not care: case 9 landing before case 2 updates row 9 and leaves row 2 pending.
dispatch(updateDsaCompilerTestResult({
caseIndex: message.caseIndex,
result: {
status: parseInt(message.result.statusId) === ACCEPTED ? 'passed' : 'failed',
statusDescription: message.result.statusDescription,
stdout: message.result.stdout,
time: message.result.time,
memory: message.result.memory,
},
}));
dispatch(setDsaCompilerProgress({
finished: message.finishedCases,
total: message.totalCases,
}));
The counters come from the server, not from the client
It is tempting to derive "finished" by counting non-pending rows in the store. Do not. The server already knows, it is authoritative across tabs, and deriving it locally means a dropped frame silently changes your progress bar into a lie.
completed — the terminal frame
Carries the final status and a full summary array. The client re-applies the whole summary rather than trusting the deltas it has accumulated, which quietly repairs anything missed, then flips status out of running.
That status flip is also what the rest of the product listens to. In the live interview, an effect keyed on the run ID sends the outcome to the interviewer model exactly once per run — tracked with a ref holding the last reported ID, so a re-render or a late frame cannot report the same run twice.
What happens when the tab reconnects mid-run
The browser retries for you — design for it
This surprises people: EventSource reconnects on its own. The HTML standard's server-sent events section requires the browser to retry a dropped stream, honour a server-supplied retry interval, and send back a Last-Event-ID header so the server can resume. The only way to stop it is close().
So a reconnect is not an error path, it is the normal path, and it has to be idempotent. Ours is, because of the connected frame: whatever the client had, it throws away and rebuilds from the record. Reconnecting three times in a flaky metro tunnel produces the same panel as never disconnecting at all.
Our escape hatch: SSE first, polling second
We did not delete the poller. We demoted it.
eventSource.onerror = () => {
eventSource.close();
if (isActive) startPolling(); // 1s interval, stops on terminal status
};
The honest reason: onerror tells you a stream failed, not why. A transient blip and a proxy that has decided to buffer text/event-stream into oblivion look identical from the handler. Some corporate networks and intermediaries still do exactly that, and a candidate mid-interview is the worst possible person to debug it with.
So on the first error we stop trusting the transport and switch to the dumbest thing that always works. We give up the browser's own retry to get a guaranteed path. It is the right call for an interview, where a stalled results panel costs someone a job, and it would be the wrong call for a dashboard.
The trade-off in one line
SSE is the fast path; polling is the path that cannot fail. Keep both, make the fallback one-way, and make sure the server state is complete enough that either one produces the same UI.
Closing the stream cleanly
An open stream is a held HTTP response, a subscriber, and a chunk of memory on both ends. Nothing about it is free, and nothing closes it for you. There are exactly three ways ours ends, and all three are deliberate.
- Terminal frame. After
completedthe client closes — on a short delay, so the final dispatches settle before the connection goes away. - Effect teardown. Unmounting the editor, or changing the run ID, closes the current stream and clears any pending poll timer.
- Server side. The handler ends the response once the run reaches a terminal status; the record expires on its own TTL afterwards.
return () => {
isActive = false; // ignore anything already in flight
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (pollingTimeoutRef.current) clearTimeout(pollingTimeoutRef.current);
};
The isActive flag matters more than it looks. Without it, a message that arrived between teardown and close dispatches into an unmounted tree, and a queued poll fires one last request for a run nobody is watching. Both are harmless once; both are a leak at volume.
What we would do differently: Redis Streams and Last-Event-ID
The weak point in this design is the snapshot. Pub/sub drops anything published while a subscriber is away, so we cover the gap by re-reading the record on every connect. It is correct, but it is a full re-sync where a resume would do.
Redis Streams fit the problem better. They are an append-only log with time-ordered IDs you can read from any point, which lines up almost exactly with SSE's own id: field and the Last-Event-ID reconnect header described in MDN's guide to using server-sent events. Stamp each case update with the stream ID, and a reconnect replays precisely the frames that were missed instead of rebuilding everything.
That would let us stop treating a reconnect as a full resync, and it would make the polling fallback a true fallback rather than a parallel implementation of the same logic. It is the next thing we will touch here.
What this bought us
- Per-case latency went to roughly zero. A case turns green when the runner finishes it, not on the next tick of a timer.
- Idle runs cost nothing. No request happens because nothing happened.
- A reload mid-run is survivable, because the run ID is the key and the server holds the truth.
- The interviewer model sees the same outcome the candidate sees, once per run, from the same terminal status.
None of that required a bidirectional transport. If the data only flows one way, one HTTP response and a browser API that has been in every major engine for years will do — and you can spend the complexity budget somewhere it actually buys something, like the voice loop we wrote about in building voice agents with the Realtime API and WebRTC. If you want to see the results panel this describes, it is the one that runs during every coding round on Taqari.
Frequently asked questions
Is SSE better than polling for long-running jobs?
+
For server-to-client updates, yes. Polling adds half your interval to every update and spends a request per tick even when nothing changed. Server-Sent Events push each change the moment it exists over one open HTTP response, so the UI updates in real time and idle runs cost nothing.
How do you stream Redis pub/sub messages to the browser?
+
The browser never talks to Redis. Your HTTP handler opens a text/event-stream response, subscribes to a channel on the server, and writes each published payload into the stream as an SSE frame. The browser sees only an EventSource connection to your own API.
Should I use Redis pub/sub or Redis Streams for SSE?
+
Pub/sub is fire-and-forget: anything published while a subscriber is disconnected is lost. Redis Streams keep an append-only log with time-ordered IDs you can replay from, which maps directly onto SSE event IDs and the Last-Event-ID reconnect header. Use Streams when missed events matter.
What happens to an SSE stream when the user refreshes the page?
+
The connection dies and a new EventSource is created on load. That only works if your run state lives on the server, keyed by a run ID, and your first frame replays the current totals. Otherwise the reloaded tab shows an empty result list for a run already in progress.
Does EventSource reconnect automatically?
+
Yes. The HTML standard requires the browser to retry a dropped stream on its own, honouring any retry interval the server sends and replaying the last event ID. You cannot opt out except by calling close(), so your server has to treat a reconnect as a normal, repeatable event.
How do you close a Server-Sent Events connection in React?
+
Keep the EventSource in a ref, call close() when the run reaches a terminal status, and call it again in the effect cleanup so unmounting or changing the run ID tears the connection down. An isActive flag stops late messages from dispatching into an unmounted tree.