When polling beats websockets
A year of trade-offs in Mohaseb. Why the obvious answer was wrong on our network conditions.
Loading essay…
A year of trade-offs in Mohaseb. Why the obvious answer was wrong on our network conditions.
Loading essay…
For most of 2024 we were certain we needed websockets. The product was a multi-tenant accounting platform with a Flutter app on the client and a Laravel backend; data had to feel live across devices, and the textbook answer is "open a websocket and push." So we did. Six months in, we ripped it out, replaced it with disciplined polling, and the user-facing latency got better. Here is what we learned, and when polling actually wins.
The argument for websockets is well-rehearsed and not wrong:
If you are building a chat app, a multiplayer cursor, or a trading view, those properties are not negotiable. You need them. Stop reading.
The accounting app is used in shops, warehouses, restaurants, and offices across rural Saudi Arabia, Syria, and the Gulf. Network conditions there are not the conditions on your laptop in a cafe. Specifically:
Our breaking point was a Tuesday morning. Power had been out overnight across a city we had several hundred users in. When the power came back, all of those phones came back online at the same minute. They all tried to reopen their websockets at the same minute. The Reverb server fell over. The users got an empty screen. We got a phone call.
You can engineer around the storm. Jittered backoff, server-side rate limits, a connection pool, a separate gateway. We did all of that. It helped. It did not feel reliable.
Polling has a property that is easy to overlook because we are trained to think of it as primitive: every request is independent. There is no shared state on the wire. There is no "is the connection still healthy?" question to answer. The client asks; the server answers; we move on.
This shape is forgiving in ways a websocket is not:
If-Modified-Since
all become available again. Websockets opt out of that infrastructure.We did not just change the URL. The polling layer that replaced our websockets has a few properties that took iterations to get right:
interface PollConfig {
intervalActiveMs: 8_000; // user is interacting
intervalIdleMs: 45_000; // tab visible, no input recently
intervalHiddenMs: 0; // tab is hidden, do nothing
jitterRatio: 0.2; // ±20% so a fleet does not lockstep
maxConcurrent: 1; // never overlap polls
cursor: 'updated_at';
}A few things worth pulling out:
When the tab is hidden, we do not poll. When it becomes visible again, we poll once immediately, then resume the interval. This single change cut our backend traffic by 60 percent and made the perceived latency better, because the foreground tab always polls within milliseconds of becoming visible.
Every poll sends the highest updated_at it has seen, and the server
returns only the rows newer than that. The response is almost always
small. Most polls return zero rows.
We collapse overlapping polls into one. If a poll is in flight when the next interval fires, we skip the next one and let the in-flight one finish. This sounds obvious; it is the source of half the bugs you will see in poll loops written in a hurry.
After two weeks of disciplined polling, the average user-perceived latency for "your transaction shows up on the second device" was 4.6 seconds. With websockets it had been 11 seconds, when it worked. The worse-on-paper protocol won on the metric that mattered.
Polling is not the answer for everything. The cases where we kept push:
Outside those, default to polling.
If your users are on the same network as your server, and the session is long, and you are paying for low latency on every change, websockets are correct.
If any of those conditions break, polling, done with discipline, is the answer that survives contact with the real world.
That is not the architectural conclusion the textbook wants. It is the one that holds up at 8 AM on a Tuesday after a power outage.
The next essay in this thread is about how we structured the outgoing queue on the client so that polling-based sync stays correct under network flap. If sync engines interest you, the series page picks up where this one ends.