Shipmind Labs

Why the grace check runs before the background timeout

· 10 min read

A banking app that wants to lock the moment it leaves the foreground also wants to let you back in with Face ID, and the biometric sheet itself sends the app to the background for under a second. Configure the first wish as backgroundAfterMs: 0 and the gesture that opens the app locks it instead, every time, with no way out. The fix is not another setting. It is an ordering decision in the code that runs when the app comes back: ask whether the absence was short enough to have been a system prompt before measuring it against any timeout.

We build this into mobile products often enough that we extracted it. sessionlock is a dependency-free TypeScript library that decides when an app should ask for authentication again, not how to ask, and not how to store secrets. The ordering described here is the part of it we most often have to defend in review, so it is worth writing down.

Two timeouts, and why one of them wants to be zero#

An app in its owner's hand and an app sitting in the app switcher are not equally exposed, so they do not get the same deadline. idleAfterMs governs a session nobody is touching; backgroundAfterMs governs a session nobody is holding. They are separate options, and the second one is usually much shorter.

The constructor makes that relationship a default rather than a rule you have to remember:

typescript
this.#options = {
  backgroundAfterMs:
    options.backgroundAfterMs === undefined
      ? idleAfterMs
      : duration("backgroundAfterMs", options.backgroundAfterMs),
  graceMs,
  now: options.now ?? (() => new Date()),
  idleAfterMs,
};

An omitted background timeout collapses onto the idle one, so the background deadline does not quietly end up longer than the idle deadline, which is the mistake that would make leaving the app safer than staying in it. And every duration goes through a check first, because the failure mode is silent:

typescript
function duration(name: string, value: number): number {
  if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
    throw new RangeError(`${name} must be a non-negative number of milliseconds, got ${String(value)}`);
  }
  return value;
}

Number("5m") is NaN, every comparison against NaN is false, and a lock configured that way never closes however long the app is left open. We would rather the app fail to start than ship a lock that is decorative.

The strictest useful configuration is backgroundAfterMs: 0: lock as soon as the app is left. That is exactly the configuration the biometric sheet breaks.

The collision#

Face ID backgrounds your app. So does a permission dialog, and a share sheet. From the lifecycle's point of view these are indistinguishable from the user swiping home, since the same change event arrives carrying the same states. iOS makes it worse by announcing leaving twice: inactive when the app switcher opens with your content still visible in it, and background only when the user moves on.

So the return path receives a departure and a return, and has to decide what kind of absence it was using nothing but its duration. There is no API that says "that was your own authentication prompt."

One question before any measurement#

background() does almost nothing. It records a timestamp, the moment the app left, and returns. It records nothing at all while the lock is already closed.

foreground() is where the decision lives, and the order of its three tests is the whole design. It clears the recorded departure, computes away as now minus that departure, and then:

  1. Grace first. If away <= graceMs, the absence is treated as a round trip through a system prompt. Last activity is set to now, and no timeout is consulted at all.
  2. Background second. Otherwise, compare away against backgroundAfterMs and lock with reason "backgrounded".
  3. Idle last. Otherwise, compare now minus last activity against idleAfterMs and lock with reason "idle".

Because step 1 precedes step 2, a zero background timeout means what it says without trapping anyone. Both halves are pinned by one test:

typescript
test("a zero background timeout locks on leaving, but not on a Face ID trip", () => {
  const time = clock();
  const leaving = lockWith(time, { idleAfterMs: 30 * MINUTE, backgroundAfterMs: 0 });

  leaving.background();
  time.advance(2000);
  const snapshot = leaving.foreground();
  assert.equal(snapshot.state, "locked");
  assert.equal(snapshot.reason, "backgrounded");

  const prompt = lockWith(time, { idleAfterMs: 30 * MINUTE, backgroundAfterMs: 0 });
  prompt.background();
  time.advance(1000);
  assert.equal(prompt.foreground().state, "unlocked");
});

Two seconds away locks. Nine hundred milliseconds away does not. DEFAULT_GRACE_MS is 1500, and it is the only thing standing between those two outcomes.

The grace path is activity, not an exemption#

The step that surprises reviewers is that the grace branch sets last activity to now rather than simply skipping the background check.

It has to. Picture a user four minutes and fifty seconds into a five-minute idle window who triggers Face ID. The trip through the sheet is under a second, so the background timeout is correctly skipped, but if the idle clock keeps running from the last tap, the session locks again a few seconds after the user authenticated. Same loop, arrived at by a different route. Completing a biometric round trip is the user demonstrating presence, so the clock restarts.

That sounds like a hole, and it would be one if the grace path could reach an expired session. It cannot, because of where expiry is judged. A session already past its idle deadline closes on the way out, in background(), rather than being carried into the absence, and background() records no departure while the lock is closed, so there is no departure for foreground() to measure and no grace branch to enter. The trip through the sheet cannot reopen something that was already shut.

What the grace period covers is a session that was still live when the app left:

typescript
test("leaving a session that is still live keeps the grace period", () => {
  const time = clock();
  const lock = lockWith(time, { graceMs: 2000 });

  time.advance(4 * MINUTE);
  assert.equal(lock.background().state, "unlocked");

  time.advance(800);
  assert.equal(lock.foreground().state, "unlocked");
});

And a longer absence still falls through to the idle test even when it is comfortably under the background limit:

typescript
test("returning after a short absence still locks if the session went idle", () => {
  const time = clock();
  const lock = lockWith(time, { idleAfterMs: 5 * MINUTE, backgroundAfterMs: 30 * MINUTE });

  time.advance(4 * MINUTE); // idle in the foreground
  lock.background();
  time.advance(2 * MINUTE); // under the background limit
  const snapshot = lock.foreground();

  assert.equal(snapshot.state, "locked");
  assert.equal(snapshot.reason, "idle");
});

Three ordered tests, three distinguishable outcomes, and a reason on the snapshot so the locked screen can say something truthful about why it is there.

The edges that keep the order honest#

Once the grace window exists, anything that can forge a short absence can forge its way past the lock. Three cases in this library exist only to close that off.

A second departure must not move the first one. iOS reporting inactive and then background arrives as two calls to background(). If the second one overwrote the recorded timestamp, the time the app sat visible in the app switcher would not count, and on a slow user the whole absence could shrink into the grace window. The first departure wins.

A return with no departure behind it is not a zero-millisecond trip. A stray active event, or the first one after a process was launched in the background, once took the grace branch with away of zero, refreshing activity and reopening an hour-old session. It is now judged on idle time, and it does not touch the activity clock:

typescript
test("an active event with no recorded departure does not refresh activity", () => {
  const time = clock();
  const lock = lockWith(time);
  const before = lock.snapshot.lastActivityAt;

  time.advance(2 * MINUTE);
  const snapshot = lock.foreground();

  assert.equal(snapshot.state, "unlocked");
  assert.equal(snapshot.lastActivityAt, before, "nothing happened, so the idle clock runs on");
  assert.equal(lock.msUntilIdleLock, 3 * MINUTE);
});

A tap on the passcode screen is not activity. touch() returns without updating anything while the state is "locked", so hammering the locked UI cannot extend a session.

An endless grace period is refused. graceMs goes through the same non-negative check as the timeouts, and then a second one: a non-finite value would treat every absence as a system prompt, so the constructor throws rather than building a lock that never closes on return.

What it costs to run#

The honest cost is the window itself. For 1.5 seconds after the app leaves the foreground, any return looks like the owner coming back from their own prompt, including a return by somebody who took the phone out of their hand. We take that trade knowingly: the alternative is making the shortest, strictest background timeout unusable on every device that authenticates with a biometric sheet, which in practice means teams set it to thirty seconds instead and lose far more than 1.5 seconds of exposure. The window is an option, so an app with a different threat model can shorten it, and the bound above (an already-idle session closes on the way out) means it cannot resurrect a dead session.

The runtime cost is close to nothing. Nothing is scheduled while the app is away, because the whole premise is that timers do not run there; the library compares timestamps instead of counting elapsed time. While the app is open, attach wakes exactly when the idle deadline falls due rather than on a fixed interval, and an idle deadline of Infinity, a lock that applies only on leaving, arms no timer at all:

typescript
const due = lock.msUntilIdleLock;
if (!Number.isFinite(due)) return;
handle = timer.set(() => {
  options.onChange(lock.check());
  schedule();
}, Math.min(MAX_DELAY_MS, Math.max(250, due)));

That poll is an optimisation so the lock appears promptly for someone looking at the screen. It is not the mechanism.

The cost we care about more is reviewability. Because AppState, the clock and the timer all arrive as arguments typed by their shape, every case above runs in a plain node --test runner with no device and no waiting, which is why the ordering of three checks in one method is something we can argue about with a test in hand rather than with a phone and a stopwatch.

The general shape is worth keeping even when you do not use this library: when two settings appear to contradict each other, the resolution usually is not a third setting. It is deciding which question gets asked first.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com