Shipmind Labs

A deep link that arrives before login: park it, replay it once, expire it

· 9 min read

Sometimes a link arrives before the app can do anything with it. A cold start resolves the URL that opened the process before the navigator exists, and a link into a screen behind a login arrives while nobody is signed in. The destination is known and valid in both cases. What is missing is somewhere to put it until the app can actually go there. The two usual answers (a module-level variable and a timeout) both replay the link at the wrong moment: twice, or twenty minutes late.

We build role-specific mobile apps and the notification fan-out behind them, web and mobile push plus chat channels, so almost every link we route was tapped from outside the app by someone whose session state we do not control. The signed-out tap is the normal case here, not the edge case. This post is about the small piece of state that case produces, and the three separate deadlines it needs. The code is from deeplinkmap, the deep-link router we maintain at https://github.com/shipmindlabs/deeplinkmap.

Two moments, one shape#

A running app is handed links by an event. A starting app has to ask for the one that opened it, and that answer lands early, before the navigation tree is mounted and usually before the stored session has been read back from disk. Navigating then either throws or navigates nothing. The link is gone.

The second moment has nothing to do with startup. A link to an order, a receipt, a document waiting for a signature is a link to a screen behind a login. Open it anyway and the screen either fails oddly or renders with no session, which is worse: that is a screen nobody was supposed to reach without one.

Both are the same shape. The destination resolved cleanly, the app cannot use it yet, so it has to be held. Everything that goes wrong afterwards is a property of that hold, not of the parsing and not of the navigator.

We have written here before about why most of a deep-link router's safety is bought at construction time, when the table either refuses to build or hands back a named refusal. This is the part that comes after a clean match.

One door, so the hold is fed once#

Before anything is held, both delivery paths have to meet. A cold start can deliver the same URL through both of them, a url event can fire twice for a single tap, and a notification reopened from the tray repeats the URL it carried. Two handlers also drift, and one of them ends up missing a check the other has.

typescript
import { LinkIntake, PendingLink, Router, type Delivery } from "deeplinkmap";

const intake = new LinkIntake(router);
const pending = new PendingLink();

Linking.getInitialURL().then((url) => handle(intake.start(url)));
Linking.addEventListener("url", ({ url }) => handle(intake.deliver(url)));

function handle(delivery: Delivery | null) {
  if (delivery === null || delivery.status === "duplicate") return;
  if (delivery.status === "refused") return; // the audit hook has it

  const { match } = delivery;
  if (match.requiresAuth && !session) {
    pending.hold(match);
    return;
  }
  navigate(match);
}

start() takes whatever the initial-URL lookup resolved to, including nothing, because no link is not a delivery. Everything else goes through deliver(). A link already seen inside dedupeWithinMs (three seconds by default) comes back as a duplicate and is not applied again, and that dropped repeat goes to the same audit hook as every acceptance and refusal, so one log holds every decision.

Two details in that window are deliberate. Links are compared as received, so two different URLs are two links even when they end up on the same screen, because folding them together would suppress a link somebody meant to send. The window is also anchored to the first sighting, which keeps a burst of repeats from pushing it out and hiding the link indefinitely.

Three seconds is chosen the way these numbers usually are: long enough to swallow a double-fire, short enough that a person deliberately tapping the same link again gets the screen. A window of zero remembers nothing, and a window with no end makes a second tap an hour later do nothing at all, so both throw InvalidIntake at construction rather than reading as dedupe and behaving as something else.

The hold stores the decision, not the URL#

hold() takes a Match, not a string, and that matters more than it looks. By the time the match exists, the host has been checked against the ones this app owns, the door the link came through has been checked against the ones the route opens to, every placeholder has been validated against its declared kind, and the whole decision has already gone to the audit hook. Replaying a Match does not re-open any of those doors.

Holding the raw URL instead means re-resolving it later, against a table that may have been rebuilt in between, and producing a second audit entry for the same link. The hold is the answer, already given.

typescript
function onSignedIn() {
  const resumed = pending.take();
  if (resumed) navigate(resumed);
}

function onSignedOut() {
  pending.clear();
  intake.clear();
}

The single-use property lives in five lines:

typescript
take(): Match<Name> | null {
  const match = this.#fresh();
  this.#held = null;
  return match;
}

The hold is released before the destination is handed back. A login callback that fires twice, a screen that remounts, a second take() from a different effect: each of them gets null. That is the difference between a resume and a loop. You consume the hold, you do not simply read it.

A newer link replaces an older one, because the last thing the person tapped is the thing they meant. clear() exists for sign-out: a destination held for one session must not be replayed into the next one.

A hold without an end is a surprise waiting to happen#

A link acted on twenty minutes late is not a resume. The person tapped it, waited, gave up, and started using the app for something else, and navigating them away at that point is worse than dropping the link. So a hold has a lifetime (expiresAfterMs, ten minutes by default), and when it is over the held match is dropped rather than hidden.

typescript
const expiresAfterMs = options.expiresAfterMs ?? DEFAULT_EXPIRES_AFTER_MS;
if (!Number.isFinite(expiresAfterMs) || expiresAfterMs < 1) {
  throw new InvalidHold(
    `expiresAfterMs ${expiresAfterMs} is not a lifetime a link can be held for`,
  );
}

Zero drops every link the moment it is held, and a lifetime with no end is exactly the replay-much-later surprise the expiry exists to prevent. Both read as a hold and behave as something else, so construction accepts neither.

The freshness check also refuses a clock that moved backwards. A timezone trip or an NTP correction produces a negative elapsed time, and a naive comparison would extend the hold by the size of the correction instead of ending it. Treating backwards as expired is the conservative direction, because the worst case there is a dropped link rather than a link replayed an hour late.

One consequence worth knowing: reading waiting runs the same check, so observing a stale hold is what drops it. No code path leaves an expired link visible.

Three different kinds of once#

The mechanisms look similar and answer different questions, and collapsing them into one is how these bugs come back.

mechanism span question it answers
intake dedupe window seconds (3 by default) is this the same delivery arriving twice?
take() releasing before it returns once, ever has this destination already been applied?
expiresAfterMs minutes (10 by default) is this still what the person meant?

A dedupe window cannot stop a resume loop, because the second take() can happen minutes after the first. Single-use cannot stop a duplicate delivery, because two deliveries are two holds. And neither one makes a link stale.

Testing it without a device#

The hold reads the clock through an injected now, which turns every timing rule into an ordinary unit test. No device, no sleeping test suite.

typescript
test("a held link is dropped once its lifetime is over", () => {
  let at = new Date("2026-08-16T10:00:00Z").getTime();
  const pending = new PendingLink({ expiresAfterMs: 60_000, now: () => new Date(at) });

  pending.hold(matched("https://example.com/order/5"));
  at += 30_000;
  assert.equal(pending.waiting, true);

  at += 60_000;
  assert.equal(pending.waiting, false);
  assert.equal(pending.take(), null);
});

The clock-moving-backwards case is the same test with a subtraction. This is the part of our review gate we do not negotiate on for anything timing-dependent: if a rule is about elapsed time, the time source is a parameter.

What it costs to run#

One slot of memory for the hold, plus a map of recently seen URLs that is pruned on each delivery. No timers, no background work, nothing to configure per environment. The router imports nothing from React Native, so all of this runs in a plain Node test process.

The hold does not survive the process being killed, and that is a decision rather than an omission, since a link held across a relaunch is on its way to being the twenty-minutes-late replay. If a particular flow genuinely needs to survive a restart, the thing to persist is the match together with the timestamp it was held at, and then the expiry is the only reason the stored link is not a trap.

The operational surface is the audit hook. Refusals and dropped duplicates are the two lines worth watching: a steady stream of foreign-host refusals is an app being probed with links to hosts it does not own, and a jump in duplicates probably means a delivery path was wired twice.

Close#

A deep link that arrives too early is a small amount of state, and almost all of the bugs around it are deadline bugs rather than routing bugs. Park the decision. Release it before you hand it back. Give it a lifetime short enough that a replay is still a resume. Each of those has to be stated separately, because no one of them implies the others.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com