Skip to content
Serhii Kuznetsov
All notes

5 min readRealtimeBrowser storageDebugging

Two windows, one client id

A shared whiteboard where one window's edits reached nobody. The protocol was right and its tests were green — the browser had copied something I had assumed belonged to a single tab.

Two windows of the same shared board, and a report I could not reproduce: "I don't always see the changes in both windows." Not never. Not always. This is the one bug in that project I could not find by reading the code — and the place it hid was not in the code.

What the tests said

Tessera is a multiplayer whiteboard with the synchronisation written by hand: the server puts every change in one order and applies each one exactly once, identified by a pair — the client id and that client's own change number. Everything else, from offline edits to undo, rests on that pair.

It is also the part the tests go after hardest: 120 seeded runs of two to four simulated clients against the real server logic through a hostile network, plus end-to-end tests that drive two browsers against the real Worker. All green.

Recording the wire instead of trusting the tests

The production build has no test hooks in it, so I could not read the editor's state there. What I could read was the wire. I drove two browser contexts with Playwright, recorded every WebSocket frame on both sides, and rebuilt each window's board from its own frames — then compared both with the snapshot the server hands to a third visitor who joins at the end, which is the closest thing to ground truth there is.

page.on("websocket", (socket) => {
  socket.on("framesent", (frame) => log.push({t: Date.now(), dir: "out", data: frame.payload}));
  socket.on("framereceived", (frame) => log.push({t: Date.now(), dir: "in", data: frame.payload}));
});

Four scenarios: one window drawing, both drawing at once, geometric shapes instead of freehand, and one with a twenty-second pause so the room would hibernate and be rebuilt from storage in the middle. Every change arrived. Both boards matched the server's, shape for shape. Median delivery 34–50 ms, worst 109 ms, nothing lost.

So the protocol was not the problem. The difference had to be in how the two windows were opened — and the app has a button for that.

The button in my own toolbar

onClick={() => window.open(location.href, "_blank", "popup,width=960,height=720")}

A browsing context opened this way does not start with empty session storage. It starts with a copy of the opener's — that is in the HTML specification, and duplicating a tab does the same. And the client id lives in sessionStorage, on purpose: a reloaded page has to stay the same client, so that whatever it sent but never heard back about is recognised instead of applied twice.

So both windows called themselves the same client. I ran the same recording again, this time opening the second window with the button:

  • both windows said hello as 87RZ5tZgi3Fj;
  • of four shapes drawn in the second window, zero reached the server — the room answered eighteen times with duplicate;
  • of four shapes drawn in the first window, the second drew none.

Why one id breaks it from both ends

The server keeps the highest change number it has applied per client. Whichever window was behind in its own numbering had its changes read as repeats of the other's and dropped — and a duplicate is not an error, it is the room saying "already applied", so the client cheerfully took those edits off its queue as done.

The other end is worse, because it is in code that is right:

if (message.change.client === this.clientId) {
    // Our own change, in its final place in the order. The view already shows it.
    this.acknowledge(message.change.n);
} else {
    const touched = this.applyRemote(message.change);
    this.events.doc?.(touched);
}

Every change the other window made arrived carrying what this window believed was its own id. So it was never drawn — it was taken for an echo of our own edit and acknowledged. And acknowledge(n) drops every queued change numbered up to n, so the second window also threw away its own unsent edits whenever the first window's numbering passed them.

Nothing on screen could hint at this. The connection pill said "Live", because it was; the queue counter is deliberately hidden while online, because during a drag it would do nothing but flicker.

Giving the id to exactly one live page

The invariant I actually needed was never written down anywhere: a client id belongs to one live page. sessionStorage cannot express that — it survives reloads, which is the point, and it gets copied, which is the problem. A Web Lock can: the browser releases it when the page is closed, reloaded or crashes.

export const claimClientId = async (room: string): Promise<string> => {
    const inherited = read<string>(sessionStorage, CLIENT_KEY);
    if (!navigator.locks) return inherited ?? newClientId();

    if (inherited) {
        // Only an id with changes queued under it is worth waiting for.
        const patience = loadPending(room, inherited).changes.length > 0 ? RELOAD_PATIENCE_MS : 0;
        if (await holdClientId(inherited, patience)) return inherited;
    }
    const id = newClientId();
    await holdClientId(id, 0);
    return id;
};

The one thing this must not break is a reload: a reloading page has to get its own id back, or the edits queued under it are stranded. That depends on the previous document releasing the lock before the next one asks, and no specification promises an order there — so I measured it. In 80 reloads out of 80, headless and headed, the lock was free by the time the new document asked; a copy, asking at the same moment while its opener was alive, was refused every time. For the one case where being wrong would cost something — unsent changes queued under that id — the page waits a second for the lock instead of taking a new id immediately.

The button also stopped handing out copies of storage: window.open(url, "_blank", "popup,noopener,…").

The tests that would have caught it

Two: one presses the app's own button, one calls window.open without noopener — which is what "Duplicate tab" does to storage — and both check that the two windows are separate clients and that drawing crosses in both directions. Run against the code as it was, they fail exactly the way the report described: the rectangle drawn in the first window never appears in the second.

What I took from it

  • My end-to-end tests drove two browser contexts, which is the precise shape of the bug they could not see. Separate contexts never share storage, so no two pages could ever share an id. The test setup was a copy of my assumption.
  • A protocol's guarantees end where its assumptions do. Exactly-once was implemented correctly and proved by simulation; it rested on "one tab, one identity", which the browser is free to break.
  • When the protocol's own tests are green against production, stop re-reading the protocol and go and look at what is different about the situation that broke.

The fix lives in src/sync/storage.ts.

Keep reading

More notes