Handling AbortController with Conditional UI
An armed conditional ceremony is a long-lived promise that your own code is responsible for cancelling, and almost every defect in a conditional-UI integration is a lifecycle problem rather than a protocol one. This page covers the four reasons you will abort, the handover that must happen inside a single user gesture, and where the controller has to live for any of it to work. It sits under Conditional Mediation and Passkey Autofill UI.
Signature Reference
| Situation | Error produced | Report it? |
|---|---|---|
| you aborted for a modal handover | AbortError |
no — routine |
| you aborted on a route change | AbortError |
no |
| the page navigated or unloaded | NotAllowedError |
count, do not alert |
| a second ceremony was started without aborting | one of them rejects | yes — this is a bug |
| the user picked a passkey | resolves with an assertion | it is the success path |
Two of the five look like errors and are not, which is why an unfiltered dashboard is unreadable within a day of enabling autofill.
Root Cause Analysis
1. No abort before the modal ceremony. The browser refuses a second ceremony while one is pending, and which of the two is rejected is not consistent. The symptom is a passkey button that sometimes does nothing.
2. An await between the abort and the new call. User activation is consumed and expires. Fetching fresh options after aborting, then calling get(), frequently lands outside the activation window and rejects immediately with no prompt shown.
3. The controller in reactive state. A state update — including one caused by the user typing in the username field — replaces the controller, and the abort then targets an object nothing is listening to.
4. No abort on client-side navigation. In a single-page application the page does not unload, so an armed ceremony survives into unrelated views and can surface the platform chooser at a baffling moment.
5. Logging every AbortError. The routine handovers bury the genuine ones, and the genuine ones are the only signal in the category.
Step-by-Step Resolution
Step 1 — keep one controller per page, outside reactive state
A ref or a module-level variable, created when the sign-in view mounts.
Step 2 — tag the controller before aborting
Record why you are aborting on the controller object or a parallel variable, so the catch handler can decide whether to stay silent.
Step 3 — abort and start in the same handler
Inside the click handler: abort the armed ceremony, create a new controller, call get() with modal mediation. No awaits in between.
Step 4 — wire the abort to the router
Whatever your framework uses for view teardown, abort there as well as on unload.
Step 5 — filter the reporting
Swallow tagged aborts; report untagged ones. This one rule makes the client error dashboard usable.
Verification and Testing
Drive the handover in a browser test: arm a conditional ceremony, click the explicit passkey button, and assert that the modal ceremony starts and completes. This is the single most valuable test in a conditional-UI integration, because the failure it catches — neither dialog appearing — is invisible in unit tests and obvious to users.
Assert the routine abort is not reported. Complete the handover and assert your telemetry recorded no error, only the successful ceremony.
Test the route change: arm the ceremony, navigate away with client-side routing, and assert the ceremony was aborted rather than left pending.
Pitfalls
1. Reusing one controller for both ceremonies. Aborting the armed one then aborts the modal one too.
2. Awaiting a fetch after the abort. Loses the activation the modal call needs.
3. Treating a pending promise as a hang. The armed ceremony is expected to stay pending for the whole visit.
Frequently Asked Questions
Why does the modal ceremony fail if I do not abort first?
Because only one credential request may be outstanding per page, and starting a second while one is pending causes one of them to be rejected. Which one varies between engines, so the behaviour looks intermittent even though it is entirely deterministic within a given browser.
Can I reuse the AbortController?
No. A controller is single-use: once aborted, its signal stays aborted, and passing it to a new ceremony aborts that ceremony immediately. Create a fresh one for each request.
Should the conditional ceremony be re-armed after a modal one fails?
Only if the user is staying on the page and the field is still there. Re-arming immediately after a refused modal ceremony is reasonable and restores the autofill suggestion; re-arming after a successful one is wasted, because the user is about to navigate.
How do I tell a routine abort from a real one?
By tagging. The error itself carries nothing that distinguishes them, so the discrimination has to come from state you set before calling abort(). A simple flag on the controller, cleared when a new one is created, is enough.
Does aborting release the server-side challenge?
No. The abort happens entirely in the browser and your server never hears about it, so the challenge sits until it expires. Short expiry and single-use consumption make that harmless.
What happens if the user submits the password form instead?
Abort the ceremony as part of the submit handler. Leaving it armed through a form submission can surface the chooser during navigation in some browsers, which is confusing at exactly the moment the user thought they had finished.
A Lifecycle Checklist for the Sign-In View
Because the failures here are all lifecycle failures, the most useful artefact is a short checklist applied to whichever component owns the sign-in surface.
On mount: probe for conditional-mediation support, fetch the ceremony options, create a controller, arm the ceremony. All four in one effect that runs exactly once, not once per render.
While mounted: leave the promise pending. Do not attach a timeout of your own, do not treat pendency as a failure, and do not re-arm on state changes. A user typing in the username field must not disturb it, because the platform filters its own suggestions and the passkey entry should remain available while they type.
On the explicit passkey click: abort with a tag, create a new controller, call the modal ceremony synchronously in the same handler. If options must be fetched, fetch them on mount and reuse them, or start the fetch in parallel with the abort rather than awaiting it before the call.
On password submit: abort with a tag, then let the form submit. Leaving the ceremony armed through a navigation is the case that produces a chooser appearing over an unrelated page.
On route change: abort with a tag. In a single-page application this is the event that replaces unload, and forgetting it is why an armed ceremony can outlive the view that created it.
On unmount: abort with a tag, unconditionally, including when the unmount was caused by an error elsewhere in the tree.
Six events, one controller, one tag. Written out like this the whole feature is small, which is worth noticing: conditional mediation has a reputation for being fiddly, and almost all of that reputation comes from the controller being owned in the wrong place rather than from the API being complicated.
Related
- Conditional Mediation and Passkey Autofill UI — the parent topic area and the ceremony lifecycle
- Implementing Conditional UI with @simplewebauthn/browser — wiring the same lifecycle into a framework
- Resolving WebAuthn Timeouts and AbortError — the error taxonomy behind this
- Debugging and Observability for Client-Side WebAuthn — filtering the reporting so the real signals survive