Article

Real-time order tracking: polling, SSE or WebSockets

Kreshnik Rraci ·

The word "real-time" hides three different engineering problems, and the most common mistake in building an order-tracking screen is picking one transport to solve all of them. WebSockets for something that changes six times an hour is the usual result.

The problem: one screen, three streams

Look at what an order-tracking view actually shows, and how each piece changes:

Stream Cadence Direction Concurrent watchers
Order / fulfilment status A handful of changes over an hour Server → client One or two
Courier position Every few seconds, for ~20 minutes Server → client One or two
Conversation with the store Sporadic, bursty Both directions Two
"Your order has arrived" while the app is closed Once Server → device One

Four update patterns with nothing in common but the screen they land on. The transport that is right for a courier's position is wasteful for a status field and wrong for a chat. Treating them as one requirement means over-engineering three of the four.

The useful reframing: classify each stream by cadence, direction and fan-out, then choose a transport per stream. The transport is an edge concern. What matters underneath is that one state-change event can feed any of them.

The options

Transport Direction Connection Infra shape Resume after drop Proxies / CDNs
Polling (conditional) Server → client None held; a request per interval Stateless, cacheable Trivial - next poll Everything works
Long polling Server → client Held until data or timeout Stateless-ish; ties up workers Client re-requests Mostly works
Server-Sent Events Server → client One held HTTP response Stateless per event; needs streaming Built in: Last-Event-ID Works; needs buffering disabled
WebSockets Both One upgraded TCP connection Stateful; sticky sessions or pub/sub You build it Most; some corporate proxies break it
Push notifications Server → device None; OS-mediated External service (FCM/APNs/Web Push) N/A N/A

Two of these are routinely underrated.

Conditional polling is not the naive option. A GET with If-None-Match that returns 304 Not Modified carries no body, is answered from a cache or a cheap lookup, and keeps every piece of infrastructure stateless. For a value that changes a few times an hour, polling every 30 seconds with an ETag is close to free, and the "latency" of up to 30 seconds is invisible on a status that changes that rarely.

Server-Sent Events do most of what people reach for WebSockets to do. They are plain HTTP: one long-lived response with Content-Type: text/event-stream. The browser reconnects automatically, sends the last event ID it saw so the server can resume, and - over HTTP/2 - the old six-connections-per-host limit that made SSE awkward is gone, because streams are multiplexed on one connection. All of that is built into EventSource; none of it has to be written.

Polling, Server-Sent Events and WebSockets compared by connection and message direction Polling client server GET + If-None-Match → 304 stateless · latency = interval Server-Sent Events client server one GET · text/event-stream one connection · server → client WebSocket client server upgrade · then frames both ways stateful · both directions
Three transports, three cost profiles. Polling holds nothing; SSE holds one response; a WebSocket holds a stateful connection you own.

The pattern: one event, a transport per stream

1. Classify first

Every stream on the screen gets three labels before anyone picks a technology:

Then apply a small rule set:

Cadence × direction Transport Because
Low cadence, server → client Conditional polling Stateless, cacheable, and the interval is invisible
Bursty or sustained, server → client SSE Built-in reconnect and resume; one HTTP response
Both directions, or sub-second WebSockets The only option that earns its stateful tier
Device may be asleep or app closed Push notification A different channel with a different consent model

Applied to the tracking screen: order status → polling; courier position → SSE; the conversation → WebSockets if it's a first-class feature, otherwise it too is polling plus a POST; the arrival alert → push.

2. Decouple the event from the edge

The transports differ at the edge. Underneath, they should all be fed by the same thing: a state-change event emitted once when a fulfilment moves from preparing to shipped, or a courier reports a position.

A single state-change event feeding a polling snapshot, an SSE stream, a WebSocket hub and a push service Fulfilment status changed preparing → shipped Event log / pub-sub append once, fan out Snapshot table → polling SSE stream WebSocket hub Push service the domain emits once; the transport is chosen per subscriber and can change without touching it
Transport is an edge decision. One event, appended once, feeds every delivery mechanism - so the mechanism can change per stream later.

This is what makes the per-stream choice reversible. If courier tracking moves from SSE to WebSockets because the client starts sending its own position, the domain code doesn't know. If polling for status becomes too chatty at scale, the same event feeds an SSE stream instead. The decision lives at the edge, where it's cheap to revisit.

3. The mechanics that make each one work

Polling that behaves. Send If-None-Match with the last ETag; treat 304 as "no change". Add jitter to the interval so a thousand clients don't align. Back the interval off while nothing changes, and reset it when something does. Pause entirely when the tab is hidden - document.visibilityState - and poll immediately on return. Done well, polling is a small number of nearly-empty requests per client per hour.

SSE that resumes. Give every event an id: and the client will send Last-Event-ID on reconnect, so the server can replay from the log rather than restart. Send a comment line (: ping) every 15-30 seconds to keep proxies from closing an idle response. Disable response buffering at every layer between the process and the client - a buffered SSE stream is one that arrives all at once, late. Make sure the event log is retained long enough to cover a realistic reconnect gap.

WebSockets that survive. Heartbeat both ways with a timeout, because a TCP connection can be dead without either end noticing. Reconnect with exponential backoff and jitter. Decide, early, how a message reaches a client connected to a different server than the one that produced it - the answer is a pub/sub backbone every node subscribes to, and it's what makes the tier stateful. Authenticate on the upgrade request; the socket inherits the session but can't easily renew it.

Push that people don't turn off. Push is for the moment the user isn't looking: arrival, a problem, a required action. It is not a transport for updates; a push per status change is how permission gets revoked. Route "informational while open" through the in-app channels above and reserve push for "actionable while away".

The trade-offs

SSE is one-directional and needs HTTP/2 to be cheap. Over HTTP/1.1 each stream is a whole connection, and browsers cap those per host. Behind a reverse proxy, it needs streaming and buffering handled correctly, which is a configuration item that is easy to get wrong and hard to notice locally.

WebSockets make the tier stateful. Load balancers need sticky sessions or the backbone described above; deploys have to drain connections; autoscaling has to account for long-lived sockets rather than request throughput. This is real operational cost, and it's the reason to use them only where the stream needs both directions.

Polling has a latency floor equal to its interval, and it does not degrade gracefully at very high concurrency - a million clients polling every 30s is 33,000 requests a second of mostly 304s. It is the right default for low-cadence streams and the wrong one for anything with real-time in the product's promise.

Per-stream transports mean per-stream code paths. Three mechanisms on one screen is more surface than one. The mitigation is the shared event underneath: the delivery code is thin because the domain code is the same.

When it flips

Checklist

  1. Have we listed every stream on the screen, with its cadence, direction and fan-out?
  2. Is there a stream we're serving over WebSockets that never sends from the client?
  3. Does polling use conditional requests, jitter, backoff and visibility pausing?
  4. Do SSE events carry an id, and is the log retained long enough to resume?
  5. If we use WebSockets, how does a message reach a client on a different server, and what happens on deploy?
  6. Is push reserved for things that are actionable while the app is closed?
  7. Does one state-change event feed every transport, or does each have its own producer?
  8. Can we change the transport for one stream without touching the domain?

The screen looks like one feature. It's three or four, and each one gets the transport it deserves - not the one the most demanding stream needs.