Throttling a socket feed without losing the latest position

On the 112 dispatch map every emergency vehicle reports its position several times a second. Multiply that by the vehicles on shift across the country and the browser receives thousands of messages a minute. Redrawing an OpenLayers layer for each one is the fastest way I know to freeze a dispatcher's screen.

This post is about the small piece of RxJS that sits between the socket and the map, and why it is shaped the way it is.

What too many updates looks like

The symptom was not a crash. The map kept working, but every interaction lagged behind the hand: a pan finished half a second after the mouse stopped, a click on a vehicle opened the wrong one because it had moved in the meantime. Profiling showed what you would expect. The main thread was spending most of its time in feature updates and style recalculation, and the frame budget was gone before user input got a turn.

Two facts made the fix obvious once written down. A dispatcher cannot perceive a position change smaller than a few pixels, and a vehicle sends far more updates than that. Most of the messages were work with no visible result.

Buffer, then keep the latest

The fix is to stop treating each message as a render. Collect messages for a fixed window, keep only the last position for each vehicle, and draw the batch once. bufferTime does the collecting; a Map keyed by vehicle id does the deduplication.

vehicle-positions.ts
// Positions arrive as { id, lat, lon, ts } several times a second.
readonly positions$ = this.socket.messages$.pipe(
  bufferTime(250),
  filter(batch => batch.length > 0),
  map(batch => {
    const latest = new Map<string, Position>();
    for (const p of batch) latest.set(p.id, p);
    return [...latest.values()];
  }),
  share(),
);

Two hundred and fifty milliseconds is a compromise. Shorter windows redraw more often for no visible gain; longer ones start to feel late when a vehicle is turning. I tested a few values on the same recorded feed.

Window Redraws per second Felt
none one per message frozen at peak
100 ms 10 smooth, CPU still high
250 ms 4 smooth
1 000 ms 1 visibly stepped

Why not throttleTime

The first version used throttleTime, which passes the first message in each window and drops the rest. That is exactly wrong for positions: the message you drop is the newer one. A vehicle could sit on the map at a point it had already left, and for an ambulance approaching an intersection that is not a cosmetic bug.

The map may be late by a quarter of a second. It may never be wrong about which position is the newest.

I wrote that rule into the team's notes after the first review, and it decided the operator: anything that drops the latest value is out.

What changed

On the recorded peak feed, redraws dropped from one per message to four per second, and the main thread went back to spending most of its time idle. The dispatch team stopped reporting the wrong-vehicle click within the first week.

The stream is now the only place the map learns about positions, which also made it trivial to record and replay a feed for testing. That was not the goal, but it is the part I use most.

  1. Mid-range office desktops with integrated graphics, two monitors, Chrome. The developer machine hid the problem completely.