Two hundred tabs exposed the wrong broadcast shape

The presence list wasn't large on its own. The expensive part was sending all of it to everyone whenever one tab changed.

Ibiang Emmanuel

· 8 min read

In a shared Reedapt project, you can see who else is there and which line they are working on. Moving to another line changes a small piece of presence state: this tab is now looking here.

Our earlier approach answered that small change with the whole list of people in the project. It sent the list to everyone.

That is easy to reason about. Each recipient replaces its old list with a fresh one. Nobody has to work out which entry changed or whether an earlier update went missing.

It became expensive in a load test with two hundred editor tabs. Those were test connections, not two hundred independent people collaborating on a film, and the test is not a supported-participant limit. It gave us a way to see what happened when a lot of tabs kept producing small updates.

The shape of the broadcast mattered more than the size of any single update.

There were two kinds of repetition

Imagine a project with twenty tabs, each changing its presence once a second. That gives the server twenty updates to handle. If each update is broadcast to all twenty tabs, there are four hundred client deliveries in that second.

Now make every delivery contain all twenty entries. We haven't just repeated the delivery. We've repeated mostly unchanged state inside it.

Under those assumptions, delivered messages grow with the square of the tab count. The number of presence entries carried across those deliveries grows with its cube. Payload bytes depend on the actual serialized entries and envelopes, so they are a separate measurement again.

That distinction is worth making. A single call to emit into a room isn't a single delivery to a browser. And replacing a full list with a patch doesn't, by itself, reduce the number of recipients or how often we broadcast.

What changes when everyone updates?Synthetic ten-second workload, not a production benchmark or supported-tab limit. Counts uncompressed application JSON, excluding transport framing, compression, joins and recovery traffic.

2,000 updates over 10 seconds; 200 modern and 0 legacy recipients in the mixed row.

D = N × (N × r × T)200 × (200 × 1 × 10) = 400,000 deliveries

Same activity, three broadcast strategies

t = 0.00 s

Snapshot on every update

Server emits
0
Client deliveries
0
Application JSON
0.00 MiB

Patch on every update

Server emits
0
Client deliveries
0
Application JSON
0.00 MiB

Coalesced patches

Server emits
0
Client deliveries
0
Application JSON
0.00 MiB

Accumulated through 0.00 simulated seconds. Bars share the full-run snapshot byte scale. Coalesced updates appear only at completed 250 ms windows; this is event accounting, not measured throughput.

StrategyServer emitsClient deliveriesJSON payload
Snapshot on every update2,000400,00011165.79 MiB
Patch on every update2,000400,00087.15 MiB
Coalesced snapshots408,000223.30 MiB
Coalesced patches408,00056.31 MiB

Patches alone keep 400,000 deliveries. Coalescing makes 8,000 in this model.

Assumptions and accounting
  • All tabs change whereabouts at the selected rate, evenly staggered; sender is included in the recipients.
  • Fixed-width synthetic entries; UTF-8 bytes of [event name, payload]. MiB = 1,048,576 bytes.
  • Only nonempty aligned 250 ms windows flush; real scheduling is relative to the previous flush.
  • First three rows are all-recipient format comparisons. Only the final row uses the rounded legacy share; each recipient receives one format.
  • Joining, leaving, recovery, inbound traffic, compression, framing, CPU and rendering are excluded.

The example uses synthetic presence entries over a ten-second window. It counts server emits, client deliveries, and uncompressed application JSON bytes separately. It is not a network-throughput benchmark or a reproduction of the old production test. Initial joins, recovery snapshots, transport framing, compression, and browser rendering are excluded.

You can also separate the equation from the payload accounting. If N tabs each produce r updates a second for T seconds, and every update reaches all N tabs, deliveries are N × N × r × T. Doubling N changes both the number of sources and the number of recipients. Changing only r changes one factor.

Change the delivery equationSynthetic all-to-all delivery count. All N tabs send changed presence at a constant rate to all N recipients, including themselves. No joining, recovery or network overhead. Not a server-capacity equation.
n * n * r * T
4,000 client deliveries

This is a count under the stated assumptions, not an equation for how fast the server will run. Message size, processing cost, connection behavior, and recovery traffic still matter.

Patches reduced what we repeated

The service now keeps track of the entries that arrived, changed, or left since the last flush. A patch contains complete changed entries in an upsert list and the keys of departed entries in a remove list.

That is a patch to the presence list, not a field-by-field description of every action. If a tab moves between several lines before a flush, the latest whereabouts can replace its earlier whereabouts in the pending update.

Presence makes that possible. If someone is now on line twelve, showing every intermediate line they crossed is usually not useful. We are not applying the same rule to someone's transcript edits. Those are document changes, with different correctness requirements.

Entries also represent tabs, not just people. One person can have more than one tab open. The patch needs to identify the entry being replaced or removed without confusing it with that person's other connection.

Coalescing reduced how often we repeated it

The browser already limits how often it reports changed whereabouts. The server also coalesces changes by project, with flushes spaced at least a quarter of a second apart.

Several updates can therefore become one patch broadcast. If two tabs change during that interval, both can appear in the same patch. If one tab changes repeatedly, the pending state can carry its latest entry rather than a history of every intermediate position.

There is a trade-off. A collaborator's marker can wait for the next flush instead of moving immediately. That is acceptable only if the marker remains useful. Batching is not a reason to hide latency or postpone important document changes.

The model uses aligned quarter-second windows to make the counts reproducible. The real server schedules against the time of its last flush, so the first flush and exact event boundaries can differ.

Would a different queue order have solved it?

FIFO starts the oldest waiting item first. LIFO starts the newest waiting item first. Either can be useful in the right system, but neither makes a server process more work per second by itself.

The queue below keeps the arrival rate and processing rate separate. Start it with arrivals faster than processing and the waiting work grows. Switch to LIFO and newer jobs can pass older ones, but the extra work hasn't disappeared. A job already being processed is allowed to finish in either mode.

A queue's order is not its capacityDeterministic, non-preemptive single-server example. FIFO/LIFO change which waiting job starts next, not processing capacity. This is not Reedapt's production queue or presence-coalescing algorithm.
Next waiting job
ρ = λ / μ3 / 2 = 1.50 offered-load ratioPositive sustained backlog growth when λ > μ. Equality has no spare capacity. A ratio above 1 is excess offered load, not more than 100% actual server utilization.
t = 0.00 s
Arrived
0
Completed
0
Waiting
0
Oldest waiting age
0.00 s
ProcessingIdle
Waiting · arrival order
Empty
Completed · last eight

Arrivals exceed capacity; changing FIFO to LIFO will not remove the backlog.

Timing rules

First job arrives at 1 / λ seconds. Processing takes 1 / μ seconds per job. Completions win simultaneous-event ties. A started job is never interrupted. Changing a control restarts the model; playback stops at 30 simulated seconds. LIFO can leave older waiting jobs behind under sustained overload.

For a simpler view of the pressure, the next equation treats work as a continuous quantity: excess work grows at the positive difference between arrival and processing rates. It starts empty, assumes constant rates, and ignores the first-arrival delay and individual-job boundaries in the queue above. It is a fluid approximation, not an exact queue-length formula or a waiting-time prediction.

Watch excess work accumulateFluid approximation from an empty start with constant arrival and service rates. Counts excess workload, not the exact number of waiting jobs. Ignores first-arrival delay, whole-job boundaries, bursts and scheduling overhead. t is simulated time in seconds.
max(0, arrival - service) * t
t = 0.00 s
0 job-equivalents
30 job-equivalents0

0 s 30 s

t is elapsed simulated time in seconds. Changing a variable restarts time.

Presence coalescing does something different from either queue discipline. It can replace an older, unsent whereabouts entry with the newest entry for that same tab. We don't need to replay every line a marker crossed before showing where it ended up. That replacement is what makes skipping intermediate presence states reasonable. It would not justify dropping transcript edits or treating every kind of work as interchangeable.

A patch needs a recovery path

A full list can replace whatever a client currently knows. A patch assumes the client has a suitable starting point.

On joining, a client gets a complete presence list and a sequence number. Subsequent patches are numbered. The editor applies the next expected patch and ignores older or duplicate numbers.

If it sees a gap, it doesn't continue applying later patches and hope for the best. It holds the existing list, asks for a fresh snapshot, and resumes from the sequence number returned with it. Further patches don't start a new synchronization request while that recovery is already in progress.

If the service can't answer immediately, the client retries with backoff. A reconnect starts from a fresh join. The snapshot never stopped being useful; we moved it to the places where rebuilding the client's view is actually the task.

Miss a patch, recover a baselineScripted protocol illustration with fictional entries. No real network or document edits.
Last applied sequence
4
Client state
Following patches
  • Ada · line 1
  • Ben · line 2

A full snapshot establishes the starting point.

The recovery example is deliberately small: it drops one numbered patch and shows the client waiting for a snapshot. It isn't a simulation of network timing or every reconnection case.

The next example adds a clock and lets you change the connection. Its losses are deliberately predictable, and recovery snapshots always arrive if the connection is up. It shows the difference between a patch that has not arrived and a sequence gap the client can detect; it does not reproduce the production retry logic.

Put the recovery path under pressureDeterministic teaching network: one patch per second for ten seconds, fixed one-way latency, selected patch losses, reliable recovery snapshots. Not Reedapt's runtime, a retry simulation or a latency guarantee.
t = 0.00 s
Server sequence
0
Client sequence
0
Snapshot requests
0
Undelivered patches
0

Client has the current baseline.

  1. 0.00 s: snapshot 0 establishes the baseline.
Network rules

Server starts at sequence 0 and creates one patch per second from 1 to 10. Loss is periodic, not a probability. Snapshots are reliable; a gap recovery costs two one-way delays. Reconnect requests a new baseline. There are no retries, jitter, transport queues or document edits. Losing the last patch cannot be detected without a later sequence or reconnect. Changing a control restarts simulated time.

Older clients still needed the full list

We couldn't assume every open editor tab would understand the new event as soon as the backend changed.

Newer clients advertise patch support when they join. The service routes them into the patch audience. Older clients receive complete lists in a separate audience, but those lists also benefit from coalescing.

A flush can therefore produce two server emits: a patch for one audience and a snapshot for the other. It doesn't send both formats to every client. The compatibility path still costs more payload for older tabs, which is why the example lets you vary that share separately.

This made the protocol change less dependent on the frontend and backend arriving at exactly the same moment. It didn't make supporting old clients free.

The optimization wasn't “never send a snapshot”

We still send snapshots when a tab joins or needs to recover. We still send them to clients that don't understand patches. For ordinary movement between lines, we send the changed presence entries, and we group nearby changes before broadcasting them.

The client tests we ran for this draft cover ordered patches, duplicates, missing sequence numbers, reconnects, sync retries, and the older full-state path. They check protocol behavior, not how many people a production project should admit.

The two-hundred-tab investigation helped expose a repeated-work problem. The useful fix was to separate three decisions we had previously bundled together: who needs the update, what they need in it, and how often they need to receive it.

More stories

Engineering