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
- Debugging and Observability for Client-Side WebAuthn — the parent triage tree and privacy-safe telemetry
- Fixing NotAllowedError in WebAuthn Ceremonies — the catch-all that also carries real timeouts
- Conditional Mediation and Passkey Autofill UI — where the conditional/modal
AbortControllercoordination originates