Both sides re-send, so the repeat has to be free
Two shells both re-assert the same open message, because neither knows the other's copy landed. So the receiver has to absorb the repeat.
By Meet ModiWhen two senders independently retry the same message because neither can confirm the other's copy arrived, duplicate delivery stops being a failure and becomes the contract. The receiver has to make a repeat cost nothing, and "nothing" includes not firing analytics twice and not pushing a second identical entry onto the navigation history.
Why there are two senders
A previous fix for a dropped message on cold start, written up in the message that opened the screen, added re-sending on both sides: the web app replays what it parked, and the native shell re-asserts against every bridge the page announces.
Neither can observe whether the other's copy landed. There is no acknowledgement in the protocol, and adding one would be a larger change than the bug deserved. So both send, and the design is correct, and the duplicate is not an accident to be eliminated. It is the price of not losing the message.
What the repeat actually cost
Two things, and both were invisible in testing because testing opens things once.
- The open event fired twice. That event is what the entire open funnel is built on, so every dashboard reading it was inflated by however often the race happened to double-fire, which is not a constant.
- The navigate is a push, so it stacked a second history entry for the screen already on screen. The student presses Back, something happens, and they are still looking at the same page. One back press spent on nothing.
The second is the one users notice and nobody reports, because "Back didn't work" sounds like your own mistake.
A guard, not a latch
function openDocument(id: string) {
// Arriving again at the document we are already on answers and stops.
if (currentDocumentId === id) return { status: "already_open" };
currentDocumentId = id;
track(DOCUMENT_OPENED, { id });
navigate(`/documents/${id}`); // push, once
return { status: "opened" };
}The distinction that matters is in the condition. It compares against the document currently open, not against a boolean that records whether anything has been opened. A different document still moves, which is what makes this a guard rather than a latch.
A latch here would be the worse bug, and it is the easy one to write. Set handled = true on the first open and you have a screen that works once per session and then silently refuses to navigate anywhere.
Returning a status rather than nothing
already_open is a real answer, not a silent no-op. The sender learns its message was received and found redundant, which is different from being dropped, and that distinction is exactly what the original bug turned on. I have made the opposite mistake before and written about it in idempotent isn't the same as honest, where a duplicate call returned plain success and the UI read that as a cue to barge ahead.
Where to put the key
This is deduplication on natural state, which is the cheapest kind: the document id is already the screen's identity, so there is nothing extra to store and nothing to expire. When no such natural key exists you need an explicit request id and a short-lived store, and that is a genuinely bigger piece of work.
It is worth checking for the natural key first. Most "we need idempotency keys" conversations I have been in turned out to be "we need to compare against the state we are already holding".
The checklist for an at-least-once receiver
- Compare against current state, never against a has-run flag.
- Count analytics once. Funnel events are the easiest thing to double and the hardest to notice.
- Replace rather than push when navigating to where you already are.
- Answer the duplicate distinguishably, so the sender can tell redundant from lost.
Absorbing the repeat is one answer. Crediting it on purpose is another, which is where we ended up with a reward endpoint that stopped trying to tell retries from real repeats.
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.