Websocket backpressure in the browser: the client cannot ask you to slow down

July 20, 20266 min readBy Harman Kamboj
WebSocketsReal-timeFrontendPerformance

Every live feed I have shipped eventually hit the same wall, and it took me a while to name it properly. The server produces faster than the tab can consume. Nothing throws. The socket never drops. The page just gets heavier and heavier until the fan spins up and the numbers on screen are eight seconds behind reality. Websocket backpressure in the browser is what you are missing, and the short answer is that the browser client does not have any. There is no lever you can pull to tell the server to wait.

This surprises people because backpressure is a solved problem almost everywhere else in the stack. It is solved on the server. It is solved inside Node streams. It is not solved on the receiving end of a browser socket, and the API shape is the reason.

What backpressure is supposed to do

TCP already has flow control built in. The receiver advertises a window, the sender is not allowed to push past it, and when the receiver stops draining its buffer the window shrinks toward zero and the sender stalls. That stall travels back up the stack. On the server side your write call starts returning false or blocking, and a well behaved producer notices and eases off.

That whole mechanism depends on the receiver actually being slow to read from the socket. Which is exactly what the browser refuses to be.

The browser reads whether you are ready or not

The WebSocket API is push based. Bytes arrive, the browser parses frames, and it fires onmessage at you. To do that it drains the OS receive buffer as fast as it can, because it has no way of knowing whether your handler is keeping up. The receive window stays wide open. TCP thinks everything is fine. Meanwhile your handler is queued behind a hundred other tasks on the main thread.

Say each message costs you three milliseconds of parsing and state updates, and they arrive every millisecond. You are falling behind by two milliseconds per message, forever, with no ceiling. Memory climbs because every pending message is a live object holding a chunk of JSON. Frames drop because the event loop never gets a gap. And the lag is monotonic, so the longer the tab stays open the worse it reads.

The one flow control signal the API does give you is bufferedAmount, and it is about data you are sending, not receiving. Useful for a chatty client on bad wifi, useless for the problem in front of you. I have watched more than one team reach for it and then wonder why nothing improved.

How it shows up in production

The reason this bites so late is that it never reproduces locally. Your dev feed is calm, your machine is fast, and the tab has been open for four minutes. The failure needs volume and time.

  • Support reports that the dashboard is slow, but only for the people who leave it open all day.
  • Latency between an event happening and the UI showing it grows steadily rather than spiking.
  • A memory profile shows a growing pile of retained message objects and closures, not a classic leak.
  • Refreshing fixes it, which sends everyone chasing the wrong theory for a week.

WebSocketStream exists, and I am still not relying on it

Chrome ships WebSocketStream, which hands you a ReadableStream instead of an event. That flips the model from push to pull, so when you stop reading the browser stops draining, and real TCP backpressure comes back. It is the right design.

It is also Chromium only right now. If you are building anything that has to work in Safari, you can treat it as a nice improvement for some of your users, not as your plan. So the patterns below are what I actually ship.

Coalesce instead of queue

This is the single biggest win and it is mostly a modelling decision. Ask whether each message is state or an event.

State means only the newest value matters. A price, an orderbook level, a player position, a progress percentage. If three updates for the same key arrive before you paint, two of them are garbage. So do not queue them. Keep a Map of key to latest value, write into it on onmessage with no rendering work at all, and flush the whole Map on requestAnimationFrame. Your render cost is now bounded by frame rate rather than by message rate, and a feed that doubles in volume costs you nothing extra on the paint side.

The parsing still costs you, which is why I move the socket into a worker whenever the message volume is high. Parse and coalesce off the main thread, post a compact diff to the UI on a timer. That does not stop the client falling behind, but it stops the client being janky while it does.

Pace it at the application layer

Events are different. An order fill, a chat message, a trade. You cannot drop those, so coalescing is not available and you need the server to genuinely slow down.

The pattern that works is an ack window. The client sends a small ack after every N messages, and the server refuses to send more than one window ahead of the last ack it saw. It is TCP flow control rebuilt one layer up, which feels silly until you realise it is the only place you have control. Pick N by measuring, then decide what the server does when a client stalls: buffer up to a limit, or drop the client and make it resync.

I lean toward dropping. A client that is 30 seconds behind on a live feed is not showing anything useful anyway, and a clean disconnect plus a snapshot resync gives you correct data faster than replaying a backlog. That path needs to be solid regardless, since it is the same one you land on after any network blip, which I went into in why your real-time UI lies after a websocket reconnect.

Split the socket

The mistake I see most is one socket carrying both kinds of traffic. High frequency state and low frequency events on the same connection means you cannot apply either strategy cleanly, because you cannot drop the mixed stream and you cannot afford to ack every price tick.

Two channels. Drop freely on one, ack carefully on the other. It also makes the decision about whether you needed a socket at all clearer, and quite often the event channel turns out to be one direction only and does not need websockets in the first place, which is the argument I made about picking SSE or WebSockets.

What I would check first

If a live UI is degrading over time, measure the gap between the server timestamp on a message and the moment you render it, and plot that over the session. If it climbs and never comes back down, you have a backpressure problem and no amount of React tuning will fix it. If it spikes and recovers, you have a rendering problem and this post is not about you.

The rest of how I approach this sort of thing is in my full-stack and performance notes, and the ElasticSwap build is the kind of product where live data being late is a correctness problem rather than a polish problem.

Building something where this matters?

I am open to senior full-stack, Web3, or AI engineering roles, fully remote and any timezone. If your product's hard part is performance under real users, the data layer, or the parts that quietly break in production, that is the work I like.

Get in touch →