The message that opened the screen was the message we dropped
A bridge queues inbound messages only while nobody is listening. One shared action, two listeners, and the eager one swallowed the other's message.
By Meet ModiA message bridge that queues inbound messages only while no listener is registered will drop messages whenever two surfaces share one action name and the wrong one registers first. The queue sees a listener present and delivers; that listener does not recognise the message, falls through its default case, and the message is gone. It was never queued, because a listener existed.
This one is self-locking, which is what made it worth writing up. The message that gets dropped is the message that would have mounted the handler that owns it.
The symptom
Kill the app, reopen it, tap a document. Sometimes the document's screen never opens. Not slow, not an error state. Nothing happens, and nothing you do afterwards recovers it, short of killing the app again and getting luckier.
Intermittent, cold-start only, and it never reproduced with a debugger attached, because attaching one changes exactly the timing that causes it.
The setup
A native shell hosts a web app in a webview and talks to it over a JS bridge. The bridge has one sensible rule for ordering: if a message arrives and nobody is listening for its action, hold it in a queue and flush when a listener registers. The web app announces readiness, the shell flushes.
Two things break that. First, execute is one action shared by every surface in the app, so "is anybody listening for execute" is not the same question as "is the right surface listening". Second, there are two listeners for it: a common one that registers eagerly, above the auth gate, and the feature's own, which arrives with a lazily fetched chunk a second or two later.

Why PAINTED was the wrong signal
The first attempt at a fix, on the shell side, was to re-send the message when the page reported PAINTED. It reduced the frequency and did not remove the bug, and the reason is worth keeping.
A page reaches PAINTED with the feature absent, because its shell timed out or because the readiness check made a fallback guess. PAINTED is also sticky: once set, it never un-sets. So a single wrong reading was final, and the message that had been eaten was the one that would have mounted the screen. There was nothing left to ask again.
Paint is a fact about pixels. What the sender needed was a fact about listeners.
Both sides, because neither can see the other
The receiver parks messages it cannot yet deliver and replays them in order when a claimant registers.
// A surface claims the `what` values it owns. The router knows nothing
// about any particular surface: the claim IS the target.
export const executeRouter = {
claim(targets: string[], handler: Handler) {
for (const t of targets) handlers.set(t, handler);
for (const t of targets) {
const parked = buffer.get(t);
if (parked) { parked.forEach(handler); buffer.delete(t); }
}
},
deliver(msg: Msg) {
const h = handlers.get(msg.what);
if (h) return h(msg); // never park while the owner is live
push(buffer, msg.what, msg); // capped: 32 distinct `what` values
},
};And the sender re-asserts against every bridge announcement rather than against paint, counting announcements per key, because every announcement is a new listener that might be the one that matters.
Three details that stopped it being a worse bug
- Cap the buffer. A long-lived webview receiving messages nothing ever claims would otherwise grow it for the whole session. 32 distinct keys, oldest evicted.
- Never park while the owner is listening, or a mounted surface gets handed duplicates of messages it already processed.
- Keep the router ignorant. The first version held a feature-shaped allow-list inside a shared service, which review correctly rejected: the next surface with a late-loading chunk would have to edit a list in a file it has nothing to do with.
The consequence that follows
Once both sides re-send, the same message arrives more than once by design, so the receiver has to make a repeat free. That turned into its own bug and its own post: both sides re-send, so the repeat has to be free.
If you take one thing from this: a queue keyed on "is anyone listening" is only correct when actions have exactly one owner. Key it on the claim instead. The same shape shows up in the translation that arrived and the screen didn't notice, where the thing arriving late was a locale chunk rather than a message.
Everything above assumes the messages arriving are mine. They are not: the same listener also receives your extensions and the HMR client, and parsing whatever turns up breaks the bridge for the rest of the session.
More Posts
Two services, one topic, either deploy order
A shared topic is only safe if the old consumer ignores what the new producer adds. One field with a default, and either side can roll back alone.
Scale on what's queued and you'll remove the pod mid-job
An autoscaler counting queued work drops to zero the moment pods claim it, so it scales into running jobs and each removal hands the work back to start over.
The model was starting and the first job didn't know
A state flag set inside the background thread instead of before launching it, so the caller that asked for the model read 'stopped' and took the slow path.
