Most bugs with incoming links (deep and universal) do not live in the parser. They live in the half-second after a URL arrives, while the app is still animating. Iris treats that window as the normal case: the next URL cancels the run in flight, cooperatively, and the last writer wins. This article shows the behaviour under deliberate abuse, and the trace that proves it.
A link that works when it arrives alone proves very little. The honest test is the burst. URLs fired back-to-back into a cold-launched app, each landing before the last has finished.
This case study records what Iris does under that pressure. Five burst scenarios from cold launch all resolve last-writer-wins: no stacked sheets, no half-applied state. The mechanism is a single intent dispatcher, existence checks, no-op elision, and cooperative cancellation that writes every abandoned run into a trace the tests can assert against. Race safety is not a belief. It is a recorded sequence of events.
Incoming links (deep and universal) are usually designed around a polite assumption: one URL arrives alone into a quiet home screen. That assumption collapses once URLs become an input language. Automated callers (test runners, Shortcuts, agents) fire URLs in bursts, often mid-animation. A person tapping a second notification does the same. The burst is the normal case.
Three common responses all fail: Queue the URLs and the app dutifully replays stale intent, walking the user through three screens they no longer want. Drop the newcomers and the app ignores the user’s most recent instruction in favour of an older one. Let the runs interleave and the result is stacked sheets, half-applied navigation, and a screen that belongs to a URL nobody is waiting for.
The honest answer is the fourth one. The newest URL wins, every run it interrupts is cancelled cooperatively at a step boundary, and a cancelled run leaves nothing behind. The user fired the second URL because they want the second URL’s destination. Everything else in this article is the machinery that makes that sentence true, and the evidence that it stays true under pressure.
The protocol is blunt: each scenario starts from a cold launch. Bursts fire back-to-back with no pauses. After five seconds we capture the final UI. All against the same demo messaging app used as Iris’s consumer.
The first scenario is the unfriendliest: six URLs to six different destinations, fired in under a second.
botmessages://conversation/scrumbotmessages://profile/designbotmessages://settingsbotmessages://composebotmessages://search?q=mockbotmessages://conversation/studioThe remaining scenarios vary the shape of the abuse rather than the volume.
Last-writer-wins held in every case. No sheet stacked on another sheet. No half-applied state survived from a cancelled run. The fifth scenario is the quiet one and the telling one: firing the same URL five times produced exactly one sheet, because four of the five fires would have changed nothing and were elided before they touched the UI.
The behaviour above is cheaper to guarantee than it looks, because there is only one path to guarantee it on. Taps and URLs route through the same intent dispatcher. The settings button does not push a screen directly; tapOpenSettings() constructs the settings URL and re-enters the same path an external URL would take. The state-aware rules (settings arrives as a sheet from the root but as a push from inside a thread) apply identically to both entry points. Tap behaviour and URL behaviour cannot drift apart, because there is no second code path for them to drift along.
Inside that path, intents compile to operations. A pure function, operations(intent:), expands each intent into a reset prefix (collapse overlays, clear filters, pop to root, dismiss sheets) followed by the destination steps. Opening a conversation, for example, expands to the reset prefix, then .effect(.scrollToConversationRow(id)), then .nav(.push(.conversation(id))). Every URL lands on a clean baseline regardless of where the app was sitting when it arrived.
Three gates trim the work before it starts: parse-time existence rejection, pre-step no-op elision, and the availability gate. At parse time, a URL whose identifier does not resolve in the app’s data is rejected outright, so downstream code never has to handle “navigate to a row that doesn’t exist”. Before any step runs, an effect that would not change observable state is elided. That gate is why scenario five collapses five identical settings fires into one: the first fire presents the sheet, and the other four are no-ops against a sheet that is already there. And between parse and route, the availability gate can degrade a parsed intent to a substitute destination or drop it outright when the feature behind it is switched off, so a disabled destination never opens by accident.
The part of this design I trust most is the part that writes things down. When a second URL arrives mid-apply, the running task is cancelled at the next step boundary, and the in-flight step records a cancelled trace event carrying its post-state. The trace recorder is not instrumentation bolted on afterwards for debugging. It is the contract the tests speak. Race tests assert against the recorded sequence of events and the final navigation state, not against “did the screen look right”.
That distinction is the whole point of calling race safety a recorded fact. There is no “I think the race was handled correctly” anywhere in the test suite. There is a sequence of events, each with its post-state, and assertions that fail loudly when the sequence is wrong. The recordings in this article are the human-readable face of the same evidence; the trace is the version a machine can hold Iris to.
One detail shows how deliberately the last-writer rule is applied. Scroll targets that have not yet been consumed (the row a conversation should land on, the avatar a profile should centre) are held in pending latches, and under rapid re-fires those latches are intentionally overwritten rather than defended.
The user fired the second URL because they want the second URL’s destination. Racing the first’s scroll into a view that is already animating towards a different row would be the wrong outcome.
from Iris’s design notesLosing a race here is not a failure Iris apologises for. It is the specified outcome, recorded like every other.
Determinism of this kind is not free, and it is worth being precise about the bill.
Every tap pays the intent-compilation toll. A tap that wants nothing more than to push a conversation still routes through intent to step compilation: the step list collapses to one entry, the elision strips the reset prefix, and Iris still runs its apply loop. At the user level the overhead is invisible; it shows up as a handful of extra trace entries per interaction.
The trace recorder is itself state the coordinator owns. It retains the full event sequence so race tests can replay it, and that retention is a real cost, not a freebie.
Most visibly: tests. In our demo messaging app (Iris’s reference consumer), the link-handling surface carries 1,318 lines of test code out of 2,620 total (about half the suite). Race handling, state-aware resolution, cancellation, and URL parsing each demand their own coverage. The same 18 intents implemented without cancellation or state-aware URLs would test in a fraction of that.
Whether that price is fair depends on how much you value tap and URL behaviour staying provably identical, and on how often your URLs arrive impolitely. None of this was whiteboarded in advance; the design settled across many small changes, each forced to re-prove the race properties before it landed, and the trace is what kept that honest.
Surviving a burst is the defensive half of the story. The other half is containment: how much of a consumer’s codebase the machinery that survives it is allowed to touch. The next piece in this series measures that footprint, down to where URL handling lives and what it cost the app around it. The full map lives on the series hub.