Resolving WebAuthn Timeouts and AbortError

AbortError and a timeout look alike at the catch block but mean opposite things: an abort is your own code cancelling a ceremony (expected, silent), while a timeout is the browser giving up on the user (a real failure to handle). Confuse them and you either spam telemetry with non-events or silently swallow genuine failures. This page separates the two, shows how to coordinate a single AbortController across the conditional and modal paths, and how to tune the timeout option so slow-but-legitimate users are not misclassified. It sits under Debugging and Observability for Client-Side WebAuthn.


Signature Reference

Symptom err.name Meaning Correct handling
You called controller.abort() AbortError Expected control flow Swallow silently
Overlapping ceremony auto-cancelled AbortError Prior conditional call replaced Swallow silently
Browser gave up after timeout NotAllowedError Real timeout Re-enable, offer retry/password
Two live ceremonies at once InvalidStateError Missing abort before new call Abort prior call first

Note the asymmetry: aborting yields AbortError, but a browser timeout yields NotAllowedError (the spec routes timeouts through the catch-all). There is no TimeoutError from navigator.credentials.get().


Root Cause Analysis

1. Treating AbortError as a failure. Path switches, navigation, and starting a modal call after a conditional one all fire AbortError. Reporting these inflates your failure rate with non-events.

2. Not aborting before an overlapping call. Starting a modal get() while the conditional one is pending throws InvalidStateError; the fix is to abort first, which itself produces a (swallowed) AbortError on the old call.

3. timeout too low. A short timeout makes the browser abandon the ceremony before a deliberate user finishes, surfacing as NotAllowedError that you might wrongly log as a dismissal.

4. Timers competing with the browser. Adding your own setTimeout(() => controller.abort()) shorter than the timeout option races the browser and yields inconsistent AbortError/NotAllowedError depending on which fires first.


Step-by-Step Resolution

Step 1 — One controller, threaded everywhere

let ceremony: AbortController | null = null;

function newCeremony(): AbortSignal {
  ceremony?.abort();                 // cancels any pending call → AbortError (swallowed)
  ceremony = new AbortController();
  return ceremony.signal;
}

Step 2 — Swallow AbortError, surface everything else

async function runGet(publicKey: PublicKeyCredentialRequestOptions, mediation?: CredentialMediationRequirement) {
  try {
    return await navigator.credentials.get({ publicKey, mediation, signal: newCeremony() });
  } catch (err) {
    if ((err as DOMException).name === 'AbortError') return null; // expected
    throw err; // NotAllowedError, SecurityError, etc. handled upstream
  }
}

Step 3 — Let the browser own the timeout

Set the timeout in options and do not add a competing JS timer. Use 60–120 seconds; the browser handles the countdown and surfaces a real timeout as NotAllowedError.

const publicKey: PublicKeyCredentialRequestOptions = {
  ...decoded,
  timeout: 90_000, // browser-owned; no parallel setTimeout(abort)
};

Step 4 — Abort on lifecycle events, not on a timer

window.addEventListener('pagehide', () => ceremony?.abort());
router.events.on('routeChangeStart', () => ceremony?.abort());

Verification and Testing

// AbortError is expected and must not be reported:
const c = new AbortController();
const p = navigator.credentials.get({ publicKey, signal: c.signal });
c.abort();
await expect(p).rejects.toMatchObject({ name: 'AbortError' });

In the DevTools WebAuthn tab, set timeout: 1000 and refuse to complete the virtual authenticator prompt to observe the real timeout arriving as NotAllowedError. Confirm your telemetry records the abort case as aborted (not failure) and the timeout case as timeout. Verify that switching from the conditional path to the modal button does not emit a failure event.


Pitfalls

1. Logging aborts as failures. They are expected control flow; classify as aborted and skip alerting.

2. Competing JS timers. Let the timeout option own the countdown; a parallel setTimeout(abort) causes nondeterministic errors.

3. Expecting a TimeoutError. Browser timeouts surface as NotAllowedError — see fixing NotAllowedError.


Related