← Deep linking

English Cymraeg

Case study ·

When URLs arrive faster than the UI

Async Digital Ltd Cardiff, UK

Abstract

A deep 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 one landing before the last has finished applying. This case study records what Deep Link Kit does under that pressure. Five burst scenarios, each starting from a cold launch, all resolve last-writer-wins: no stacked sheets, no half-applied navigation, no leftovers from a cancelled run. The mechanism is one intent dispatcher shared by taps and URLs, existence checks at parse time, elision of fires that would change nothing, and cooperative cancellation that writes every abandoned run into a trace the tests assert against. Race safety here is not a belief about the code. It is a recorded sequence of events.

Most deep-link bugs do not live in the parser. They live in the half-second after a URL arrives, while the app is still animating towards the previous one. Deep Link Kit treats that window as the main case: the next URL cancels the run in flight, cooperatively, and the last writer wins. This article shows that behaviour holding under deliberate abuse, and the trace that proves it held.


§1·Pattern

The race is the normal case

Deep linking is usually designed around a polite assumption: one URL, arriving alone, into an app sitting quietly at its home screen. The assumption holds right up until URLs become an input language. A test runner drives the app through URLs. A Shortcut fires one while a screen is mid-transition. An agent issues the next instruction before the last has settled. A person taps a second notification because the first was the wrong one. In every one of those cases, URLs arrive in bursts, and the burst is not an edge case. It is how the input behaves once anything automated is on the other end.

There are three tempting answers to a burst, and each of them is dishonest in its own way. 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.

§2·Evidence

Last writer wins, five times over

I wanted a recorded answer, not a reassuring one. So the protocol is blunt. Each scenario starts from a cold app launch. Each burst fires its URLs back to back with no pauses between them. The run then waits five seconds and captures the final UI. The app on the receiving end is a demo messaging app built as the kit’s consumer.

The first scenario is the unfriendliest: six URLs to six different destinations, fired in under a second.

Fig 1 Scenario one, recorded. Six URLs fired back to back from a cold launch; five are cancelled in flight and the burst resolves to a single pushed conversation.

The remaining scenarios vary the shape of the abuse rather than the volume.

  1. Six different destinations: a conversation, a profile, settings, compose, search, then a second conversation. Final state: the last conversation pushed, nothing else mounted.
  2. Four messages in the same thread, in ascending order. Final state: the thread pushed, scrolled to the last message.
  3. Compose, an expanded avatar, an expanded attachment, then a profile. Final state: the profile pushed.
  4. 10 fires alternating between two conversations. Final state: the last conversation pushed.
  5. Five identical settings URLs. Final state: one settings sheet, no stacking.

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.

§3·Mechanism

One dispatcher, no second path

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.

Two gates trim the work before it starts. 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”. And before any step runs, an effect that would not change observable state is elided. That second 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.

§4·Trace

Cancellation as a recorded fact

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”.

Fig 2 A burst, recorded. The first run is cancelled at a step boundary when the second URL arrives; the cancelled step still writes a trace event carrying its post-state, and the tests assert against that recorded sequence, not against how the screen looked.

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 the kit 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 the kit’s design notes

Losing a race here is not a failure the kit apologises for. It is the specified outcome, recorded like every other.

§5·Cost

What the design pays for

Determinism of this kind is not free, and it is worth being precise about the bill.

Caveat

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 the kit 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. The deep-link surface carries 1,160 lines of test code in a consumer suite of 2,041, more than half. 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 deep-linking hub.