Product

SSE vs WebSockets for Live Session Streaming — Why We Chose SSE

We stream live user sessions to a dashboard using server-sent events. Here is why we picked SSE over WebSockets, how the fan-out works, and the six bugs that made a simple design take much longer than expected.

Purushottam Kumar Suman
Purushottam Kumar SumanSeptember 22, 202613 min read
Founder & CEO, DeepSync
Server-sent events streaming live session data

Short answer: watching a live session is a one-way flow, from server to viewer. Server-sent events fit that exactly, run over plain HTTP, and pass through existing authentication and proxies. WebSockets are the better choice when both sides must send, which is why we use one only for the separate two-way support channel. The protocol choice was the easy part. Almost all the difficulty was in buffering, process boundaries, and the player's assumptions about time.

Table of Contents

  1. What We Needed
  2. SSE and WebSockets Compared
  3. The Architecture
  4. Bug 1: The Heartbeat That Killed the Stream
  5. Bug 2: Two Hidden Buffers
  6. Bug 3: Two Workers, Half the Events
  7. Bug 4: The Cluster Client With No Pub/Sub
  8. Bug 5: Two Clocks
  9. Bug 6: A Player That Ignored Events Silently
  10. What We Would Tell Ourselves
  11. Key Takeaways

What We Needed

Our dashboard lets a team watch a visitor's session as it happens (live session viewing). The visitor's browser already sends recording events to our ingestion service in small batches. The task was to forward those to anyone watching, with little delay.

The first version polled for new events four times a second. It worked and wasted most of its requests. We wanted a push.

SSE and WebSockets Compared

Server-sent eventsWebSockets
DirectionServer to clientBoth ways
TransportOrdinary HTTP responseProtocol upgrade
Proxies and load balancersUsually work unchangedSometimes need configuration
AuthenticationNormal request headersNeeds its own approach
ReconnectionBuilt into the browser APIYou write it
DataTextText or binary
ComplexityLowHigher

For a viewer, the flow is one-directional: events arrive, the player renders them. The viewer never needs to send anything on that channel. SSE matches the shape of the problem.

One detail: the browser's built-in EventSource cannot send an Authorization header. We read the stream with fetch instead, so the request goes through the same authentication as every other API call.

We do use a WebSocket elsewhere, for co-browse guidance, where an agent and a visitor exchange messages in both directions. That is the right tool for that job. One channel does not have to do everything.

The Architecture

There are two paths, deliberately separate.

  • The durable path. Each batch from the visitor is written to storage as a compressed chunk. This is what recorded replay reads. It must never lose data.
  • The live path. The same batch is published to an in-memory channel for that session. Anyone subscribed receives it immediately. It can drop data without harming the recording.

Details that matter:

  • A pluggable bus. One implementation keeps channels in process memory, needing no infrastructure. Another uses Redis pub/sub so several server instances can share channels.
  • A warm buffer. Each channel keeps the latest full snapshot and a bounded tail of recent events. A viewer who joins mid-session gets a bootstrap frame with those and can render straight away, without reading from disk.
  • Publish is cheap when nobody is watching. Most sessions have no viewer.
  • Frames. A bootstrap on connect, then append frames as batches arrive, with a comment-line heartbeat to keep the connection open.
  • Presence. A short-lived record per active session answers "who is on the site now" without scanning the sessions table.

After the fixes below, the measured delay from ingestion to a pushed frame was around 40 milliseconds.

Bug 1: The Heartbeat That Killed the Stream

Symptom: live view showed the first snapshot and then stopped updating, or lagged badly.

The stream handler waited for the next event with a timeout, sending a heartbeat when the timeout fired. The call we used for that cancels the awaited operation when it times out. Cancelling it destroyed the generator producing events. So every quiet period ended the stream. The browser reconnected, received a new bootstrap, and lost whatever arrived in the gap.

The fix was to keep the same pending wait alive across heartbeats, using a form of waiting that does not cancel what it waits for.

Our unit tests had passed. They read the stream with a simple client that did not exercise a long idle period. We found the bug by building a page that recorded itself and watched itself in a real browser.

Bug 2: Two Hidden Buffers

Symptom: events arrived in bursts, seconds late.

Two layers were collecting the response before sending it.

  • A middleware in the API, written using a base class that buffers streaming responses.
  • Response compression in the proxy in front of it, which waits for enough data to compress.

Neither had anything to do with live streaming, and both were reasonable in isolation. We rewrote the middleware to pass bodies through untouched, excluded event streams from compression, and flushed after each frame.

If a stream ever feels laggy, look for a buffer you did not know about before you look at your own code.

Bug 3: Two Workers, Half the Events

Symptom: in production, events went missing at random.

The API ran as two worker processes. The in-memory bus keeps its channels in a dictionary inside one process. A batch arriving at worker A was invisible to a viewer whose stream was held by worker B.

We reproduced it with two real workers: 30 events ingested, 14 delivered. About half, scattered unpredictably, which is exactly what you would expect from requests being distributed across two processes.

An in-process bus is correct for a single process and silently wrong for more than one. The fix is a shared bus, or one worker.

Bug 4: The Cluster Client With No Pub/Sub

Symptom: after moving to a clustered Redis service, live view failed completely.

The asynchronous cluster client in the Redis library version we used has no pub/sub method. Every subscription raised an error. Viewers saw only the initial load from storage.

The fix was to keep key reads and writes on the cluster client and run pub/sub through a plain client connected to a single node. In a Redis cluster, a published message is broadcast to every node, so subscribing on one receives everything.

Going from half the events to none was, oddly, progress: a total failure is much easier to find.

Bug 5: Two Clocks

Symptom: live view appeared frozen for some visitors, while page navigations still showed.

The player schedules each event by its timestamp relative to a baseline. We started live playback using the viewer's current time as the baseline. Event timestamps come from the visitor's clock. If the visitor's clock ran ahead of the viewer's, every event was scheduled in the future by exactly the difference.

In a test harness with a 30-second skew, the old logic applied none of 18 mutations. The fix was to derive the baseline from the event stream itself: the latest event's timestamp minus a small playback buffer. Both sides of the subtraction are now in the same clock.

Never mix timestamps from two machines in one calculation.

Bug 6: A Player That Ignored Events Silently

Symptom: after the earlier fixes, live view sometimes got worse and froze permanently.

The replay library decides whether a player is live or recorded when the player is constructed. A player built in recorded mode sits in a paused state that has no handler for incoming events. It accepts them and renders nothing, with no error.

Our dashboard learned that a session was live from a presence check that refreshed every few seconds, often after the first events had arrived. So the player was frequently built in recorded mode and then told the session was live. An earlier fix had removed the one dependency that used to force a rebuild and had accidentally been masking this.

The fix was to treat the live flag as something that requires rebuilding the player when it changes.

A related fix: we had been tracking progress with an index into an array that was re-based on every new snapshot. After a navigation the index pointed at the wrong event. We now index the full append-only list and pass snapshots through like any other event, so a live navigation needs no rebuild at all.

What We Would Tell Ourselves

  1. Pick the simplest transport that fits the direction of data. We did not regret SSE once.
  2. Test in a real browser against real idle time. A self-recording diagnostic page found what unit tests could not.
  3. Suspect buffers first. Middleware, compression, and proxies all buffer by default.
  4. Know your process model. In-memory state stops being shared the moment there is a second process.
  5. Read the client library, not just its documentation. A missing method cost us a production outage.
  6. Keep each calculation in one clock.
  7. Distrust silent states. A component that accepts input and does nothing is the hardest bug to see.
  8. Reproduce before you fix. One round of fixes shipped on a theory and made things worse.

Key Takeaways

  • SSE suits one-way streaming and works with ordinary HTTP infrastructure.
  • Separate the durable path from the live path.
  • Most real problems were buffering, process boundaries, and clock assumptions.
  • Reproduce in a real browser before shipping a fix.

Conclusion

Choosing SSE took an afternoon. Making live view reliable took weeks, and none of that time was spent on the protocol. The transport is rarely the hard part of a real-time system.

Read about live session viewing.

Frequently Asked Questions

Was this article helpful?

Ready to understand
users like never before?

Join thousands of teams who use DeepSync to uncover insights,improve experiences, and build better products—faster.

Quick & easy onboarding
See results in real time
Enterprise-grade security