Module 17 · Lesson 1 of 14Free
WebSockets, SSE and long polling compared
- Level
- Intermediate
- Topic
- Realtime systems, chat and social feeds
- Reading time
- 19 min
When you finish this page you can
- Explain what stays open in long polling, SSE and WebSockets.
- Choose a transport from message direction, traffic and infrastructure constraints.
- Separate connection recovery from recovering application state.
- Design bounded buffers and recovery behavior for slow or disconnected clients.
Builds on 2 lessons
Start with Module 02, lesson 314 minVideo lesson · coming soon
marks Asha’s order as shipped. Her tracking page still says “Packing” for another 10 seconds. She refreshes twice because she thinks the shipment failed.
For this lesson’s scenario, 20,000 tracking pages are open during a festival sale. How should changes reach those pages quickly without making every browser keep asking?
Keep the request waiting instead
The simplest implementation uses : request the current order, wait, then request it again. Most requests return an unchanged order.
A long poll has a 25-second timeout. The order changes 2 seconds after the request starts. Must the browser wait another 23 seconds?Show answer
The important distinction is not “ versus something fast”. It is what happens after the server sends an update.
Diagram not drawn yet: realtime-transport-lifecycles.svg
Three independent timelines. Long polling sends an update and ends the response, then the client requests again. SSE sends two updates inside one unfinished HTTP response. WebSocket completes a handshake, then client and server both send messages without new HTTP requests.
A request ending does not necessarily mean its underlying network connection closes. can reuse an HTTP connection. Conversely, an SSE response can remain unfinished for many updates. These are different response lifecycles, not instructions to repeat a handshake every time. 1
Kartly already has separate order and payment services at this stage of the course. We will change only the tracking delivery path. Checkout remains an ordinary request and response.
First, follow one update through each transport.
Long polling ends with news
Suppose the page knows order version 41. A version is a server-assigned number that increases whenever that order changes.
Kartly could define this application contract:
GET /v1/orders/ord_7/changes?after=41
Accept: application/jsonThe tracking server authenticates Asha and checks that she owns the order. Then it:
- Checks the current version.
- Returns immediately if a newer version exists.
- Otherwise registers a waiter for that order.
- Rechecks the version after registration.
- Waits for a change or the deadline.
The second check closes a race. Without it, an update could happen between the first check and registration. The request would then wait despite newer data being available.
When news arrives, the server returns a complete current :
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"order_id":"ord_7","version":42,"status":"SHIPPED"}The client immediately starts another request using version 42. In our contract, an empty deadline returns 204 No Content; the client repeats with its existing version.
Holding a request until data or a , then issuing another request, is the long-polling mechanism. 1
The server must remove the waiter when the client disconnects or the deadline expires. Otherwise, abandoned requests leave memory behind.
Long polling is useful when your infrastructure handles ordinary responses well but cannot stream them promptly. Its cost becomes clearer during frequent updates: every completed response requires another request. A snapshot check on the next request covers the gap between them.
SSE keeps one response open
With , or SSE, the browser opens a stream:
GET /v1/orders/ord_7/events
Accept: text/event-streamThe tracking server sends response headers, then keeps writing:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-store
id: 42
event: order
data: {"order_id":"ord_7","version":42,"status":"SHIPPED"}
: keep-aliveSSE uses UTF-8 text. A blank line ends an event, and a colon-prefixed line is a comment. The event field names the event type; data carries its content. 2
Our sends complete order snapshots. JSON is Kartly’s payload choice, not a requirement of SSE.
The browser’s EventSource API handles stream parsing and reconnects after recoverable interruptions. When reconnecting the same object, it sends the remembered nonempty event ID in Last-Event-ID. That does not make the server retain or replay anything. 2
“One-way” describes this stream, not the whole application. Asha can still send a cancellation through a separate request. Occasional user actions do not require a two-way stream.
The tracking handler must flush each completed event. A buffering intermediary can otherwise turn several timely writes into one late delivery. HTTP streaming’s buffering and timeout issues require end-to-end checks. 1
This makes SSE a good fit for our tracking page, but it does not yet explain how an update finds the server holding that page.
WebSockets carry messages both ways
A establishes a persistent two-way message channel. In the HTTP/1.1 handshake, the client requests an upgrade and the server accepts with 101 Switching Protocols. Afterward, the connection carries WebSocket frames rather than ordinary HTTP responses. Text and binary messages are supported. 3
For a possible support-chat feature, Kartly could define these application messages:
{"type":"send_message","client_message_id":"m_18","text":"Can I change the address?"}{"type":"message_saved","client_message_id":"m_18","message_id":"msg_92"}Those message types are our design, not built-in WebSocket operations. The application must define validation, permissions, acknowledgments and recovery.
Why distinguish acknowledgment from sending? A connection might fail after the server saves a message but before the acknowledgment arrives. Retrying needs a stable message identity, as covered in Idempotency: making retries safe.
WebSocket Ping and Pong frames can check whether the peer responds. They do not confirm that an application saved a chat message. 3
For tracking alone, two-way messaging adds an application protocol without removing our recovery work. Choose it when both directions benefit from sustained message exchange, not because “live” appears in the requirement.
Choose for the traffic you have
Our scenario requires a changed order to appear within about one second during healthy operation. Most customers only watch. Cancellation is occasional.
Which transport should Kartly use for the tracking page?
Long polling.
Each response completes on news or a deadline. This works through infrastructure that supports ordinary responses, but frequent news causes frequent replacement requests.
SSE for tracking, with HTTPS requests for actions.
The server writes snapshots into one ongoing response. User actions keep using existing HTTPS endpoints, and the browser provides stream parsing and reconnection behavior.
WebSocket for tracking and actions.
Both sides exchange messages through one channel. This is useful for sustained two-way traffic, but tracking would gain little from moving occasional actions into a new message protocol.
Why this choice
SSE matches the problem’s direction: changes mostly travel from server to browser. We choose it only after checking that the deployed gateway forwards events promptly.
Prefer long polling if streaming cannot be made to work through a required intermediary. Prefer WebSockets if this screen becomes an active conversation with frequent messages in both directions. Keep ordinary polling if delayed updates are acceptable and its measured request cost is small.
The decision concerns request overhead and application behavior. It does not establish a universal ranking. A slow database or buffered gateway can dominate any transport.
Count requests and waiting state
These are illustrative estimates for the tracking cohort, not Kartly-wide traffic measurements.
Ordinary polling every second
20,000 pages ÷ 1 second = 20,000 requests/second
This meets the intended polling interval, but most responses may contain unchanged state.
Idle long polls with a 25-second deadline
20,000 pages ÷ 25 seconds = 800 requests/second
This assumes no changes and staggered deadlines. News ends requests sooner; synchronized deadlines cause bursts.
SSE with an illustrative 15-second heartbeat
20,000 streams ÷ 15 seconds ≈ 1,333 comment writes/second
These are small writes, not replacement HTTP requests. Actual order updates add more writes.
All three proposed live approaches still need state for roughly 20,000 waiting clients. Fewer requests do not mean zero connection cost.
Updates must find their subscribers
A transport delivers bytes from a tracking server to a browser. It does not tell that server about a change committed elsewhere.
Here is the smallest shared delivery path we need for Kartly’s existing services:
Diagram not drawn yet: kartly-tracking-delivery-path.svg
The browser opens an SSE stream through the gateway to the tracking service. The order service commits snapshots in the order database, then publishes wake-up signals through a broker to the tracking service. The tracking service reads the latest snapshot from the database and forwards it to the browser.
| Component | Responsibility | Talks to | State kept | Failure behavior |
|---|---|---|---|---|
| Gateway | Route and stream | Browser, tracking service | Open routes | Browser reconnects |
| Order service | Validate and commit changes | Database, broker | Business operation state | Tracking shows last committed state |
| Database | Store current orders | Order and tracking services | Durable snapshots | Mark tracking stale; retry reads |
| Broker | Announce changed order IDs | Both services | Subscription routing | Reconciliation reads repair missed signals |
| Tracking service | Authorize and deliver | Gateway, broker, database | Subscribers and bounded output | Reconnect and reload |
The order service owns each snapshot. Its database row includes id, customer_id, version and status. The serves reads by order ID.
For this design, the service updates the status and increments the version in the same . Concurrent changes to one order therefore receive distinct increasing versions. Business rules decide which transitions are allowed; the transport does not.
The write path is:
- The order service validates the requested transition.
- It commits the updated snapshot and version.
- It publishes the order ID as a wake-up signal.
- The tracking service reads the latest committed snapshot.
- It sends that snapshot to local subscribers.
The read path starts differently:
- The browser opens the stream through the gateway.
- The tracking service verifies ownership.
- It registers for wake-ups before reading the current snapshot.
- It sends the snapshot, then forwards newer versions.
- It removes the subscription when the stream closes.
Registering before reading prevents a gap between the initial snapshot and live delivery. Duplicate notifications are harmless because versions identify newer snapshots.
Publishing after commit avoids announcing uncommitted state. However, a crash between commit and publish still loses the signal. We need recovery independent of that signal.
Reconnection is not state recovery
Imagine Asha received version 41. Her network drops. Kartly commits version 42, but she reconnects only afterward.
Opening another connection does not reconstruct the missing information. The application must choose what to recover.
Must the tracking page receive every transition it missed?
Recover current state.
On every connection, send the latest complete snapshot. The customer sees the current status without replaying intermediate states. Missed wake-ups are repaired by periodic snapshot checks.
Recover every event.
Store an ordered history and replay entries after the client’s cursor. This preserves intermediate events, but requires retention rules, duplicate handling and an expired-cursor response.
Why this choice
Our tracking page promises the current order status, not a complete activity history. Receiving “Shipped” directly after “Packing” is acceptable even if another internal state occurred between them.
Choose replay when each event matters independently, such as individual chat messages. A snapshot of the newest message cannot replace the messages before it.
Our SSE endpoint therefore sends the latest snapshot on every connection, even when Last-Event-ID is present. Its id is the order version, not a promise of replay.
For this scenario, the tracking service also checks subscribed orders every 30 seconds. It batches these reads by order ID and sends only newer versions. This repairs missed broker signals even when the browser connection stays open.
That repair interval permits delayed freshness during failures. It is not the healthy-operation target.
Keep old snapshots from moving backward
A live update and a recovery read can finish in different orders. Suppose version 43 arrives before a slower read returns version 42.
The browser owns this small merge function. Both examples assume the payload has already passed runtime validation.
from dataclasses import dataclass
@dataclass(frozen=True)
class Snapshot:
order_id: str
version: int
status: str
def merge_snapshot(
current: Snapshot | None,
incoming: Snapshot,
) -> Snapshot:
if current is None:
return incoming
if incoming.order_id != current.order_id:
raise ValueError("Different order")
if incoming.version > current.version:
return incoming
return current
current = Snapshot("ord_7", 43, "DELIVERED")
late = Snapshot("ord_7", 42, "SHIPPED")
print(merge_snapshot(current, late).status)type Snapshot = Readonly<{
order_id: string;
version: number;
status: string;
}>;
function mergeSnapshot(
current: Snapshot | null,
incoming: Snapshot,
): Snapshot {
if (current === null) return incoming;
if (incoming.order_id !== current.order_id) {
throw new Error("Different order");
}
return incoming.version > current.version ? incoming : current;
}
const current: Snapshot = {
order_id: "ord_7", version: 43, status: "DELIVERED",
};
const late: Snapshot = {
order_id: "ord_7", version: 42, status: "SHIPPED",
};
console.log(mergeSnapshot(current, late).status);Both print DELIVERED. Equal versions are ignored too. This relies on one important invariant: equal versions describe the same immutable snapshot for that order.
The function does not recover missing chat messages. It works because each tracking payload replaces the entire displayed state.
Common mistakes
- Choosing WebSockets to guarantee delivery. A persistent channel does not replace stored state, message identities or recovery rules.
- Holding a database transaction while waiting. Read and release database resources. Wait in the connection-handling layer.
- Treating an open stream as fresh data. A missed broker signal can leave a healthy connection showing old state. Measure freshness and reconcile.
- Changing transport to fix buffering. First locate the delay. A different protocol may introduce work without fixing the failing intermediary.
These mistakes share a cause: asking the transport to solve a problem owned by another layer.
Keep waiting clients inexpensive
Use the waiting model from Server concurrency. A parked stream should not reserve an expensive worker or a database connection.
Set a byte limit on each client’s output buffer. For our complete snapshots, an unsent older snapshot can be replaced by the newest one. If writes remain blocked, close the stream and let recovery reload current state. Backpressure explains the broader mechanism.
As an illustrative memory budget, allowing 64 KiB of buffered output per stream permits about 1.22 GiB across this cohort. That excludes connection objects, and operating-system buffers. A small per-client allowance becomes significant when multiplied.
Choose a interval below the measured idle timeout of the deployed path. A heartbeat is not a delivery acknowledgment. Detect stalled connections with write deadlines and client freshness checks.
| Failure | Customer sees | Detect it with | Recovery |
|---|---|---|---|
| Tracking process stops | Disconnected indicator | Connection errors | Reconnect and reload |
| Gateway buffers events | Status arrives late | End-to-end event timing | Fix buffering; verify again |
| Broker signal is missed | Old status on an open stream | Version reconciliation | Send current snapshot |
| Database reads fail | Tracking marked stale | Read failures and age | Bounded retries |
| Client reads too slowly | Delayed updates | Buffered bytes, write deadline | Replace pending snapshot or disconnect |
Protocol and authentication details
The WebSocket handshake described earlier is specifically the HTTP/1.1 form. Do not apply its upgrade headers to other HTTP versions.
Native browser EventSource exposes a URL and a credentials option, not arbitrary request headers. For this same-origin example, use Kartly’s existing session. A bearer-token-only interface may need a different streaming client. 2
Authorize every subscription before sending data. Also define how logout, session expiry and permission changes terminate existing streams. Never assume permission remains valid because a connection remains open.
Where this shows up
- Design: live sports commentary: one-way updates, with recovery when individual entries matter.
- Design: a chat app like WhatsApp: two-way messages and application acknowledgments.
- Design: a social media news feed: notifications can announce changes without carrying the complete feed.
Next, Scaling WebSocket connections to millions addresses how connection ownership and resource limits change across many servers.
Check your understanding
A long-poll response is lost after the server sends version 42. The client retries with after=41. Should it wait for version 43?Show answer
A tracking page uses SSE, but customers occasionally press Cancel. Must it switch to WebSockets?Show answer
An SSE client reconnects with Last-Event-ID set. Has the protocol guaranteed that missed events will be replayed?Show answer
Version 43 arrives before a recovery read returns version 42. Which snapshot should the browser display?Show answer
A slow client has several unsent complete order snapshots. Can the server keep only the newest one? Would that also work for chat messages?Show answer
Summary
- Deliver changes without repeated empty checks: long polling waits before replying, SSE keeps a response open, and WebSockets exchange messages both ways.
- Choose from traffic direction and infrastructure constraints, not a universal speed ranking.
- Keep stored state separate from live delivery; an open connection does not guarantee current data.
- Recover tracking with complete snapshots and increasing versions. Use retained event history when every missed item matters.
- Bound output buffers, release database resources before waiting, and measure freshness across the entire delivery path.