Skip to content

Capability gating that cannot hang

S edited this page Aug 20, 2026 · 3 revisions

Capability gating that cannot hang

The first thing a WebXR app does is ask whether the device can do AR:

const supported = await navigator.xr?.isSessionSupported('immersive-ar');

The first thing a WebXR app must survive is that promise never settling.

Why it hangs

isSessionSupported is not a lookup table. Depending on the platform it may talk to a system AR service, wait for a component to initialise, or probe hardware. Most of the time that is fast. Sometimes — a device where the AR service is mid-update, an emulator, an unusual OEM build, a browser substituting a partial implementation — it takes a very long time or never answers at all.

A promise that never settles is worse than a rejection. There is no error to catch, no event to handle, and your await sits there permanently. Users see a spinner where a button should be, and your error reporting shows nothing at all, because nothing failed.

Guard the probe with a timeout

The fix is unglamorous: race the probe against a timer and treat a timeout as its own outcome.

const outcome = await probeImmersiveArSupportOutcome();
// → supported | unsupported | timeout | error

Note the four-way result rather than a boolean. "We asked and it said no" and "we asked and never heard back" are different situations that deserve different words in front of the user, and collapsing them into false throws away the distinction at the only place you still have it.

The framework wraps this as probeImmersiveArSupport() and probeImmersiveArSupportOutcome() precisely so that no consumer has to remember to add the timeout — which they will not, because the failure never reproduces on the developer's own phone.

Four outcomes, four sentences

Once you know the outcome, the user needs a sentence — and there are four situations here that deserve different words:

  • No WebXR at all (iOS Safari today) → say the experience needs a supported Android browser. Do not imply the user has done something wrong.
  • WebXR present, immersive-ar unsupported → likely a desktop browser or a device without ARCore. Offer the desktop path if you have one.
  • Geolocation blocked → a permission problem, and recoverable. Say how.
  • Probe timed out → say the check did not come back, and offer a retry while being honest with yourself that it may not help: the usual cause is a wedged system AR runtime, which does not reliably clear between attempts.

One sentence for all four — "AR is not supported on this device" — is the version that generates support email.

This project keeps the outcomes distinct where it matters: its permission layer turns "no WebXR API", "AR unsupported", and "the check failed or timed out" into three different sentences, and the last one deliberately keeps a try-again framing because the answer is unknown rather than a confirmed no.

The seam to watch is that a second, simpler message helper still takes only "which capabilities are missing" and never sees the probe outcome at all. Two message paths, one of which cannot express the distinction, is how a carefully-preserved four-way result quietly collapses back to a boolean.

Errors during the session

The same reasoning applies once a session is running. getXrErrorMessage() turns platform exceptions into human sentences, and there is a category of problem no message can fix: browser bugs.

This project carries a workaround for a Chromium crash involving projection layers and camera access, and it has two halves with different scopes. The projection-layer hooks are removed on every build, forcing the older layer type; only an additional patch is pinned to the affected version range.

That split is deliberate rather than sloppy. Version detection is itself a thing that goes stale — a range that stops matching leaves users crashing on a build nobody thought to add — so the cheap, harmless half runs everywhere and only the invasive half carries a version test. Both are named for the bug they work around, which is what makes them removable later instead of permanent.

Design for the honest 'no'

A capability-gated experience needs a real answer for unsupported devices, decided early rather than bolted on:

  • A desktop path. Several demos here run without WebXR at all — a walk simulator, or replay of a recorded session. The visitor sees the thing working even if they cannot do it themselves right now.
  • A clear statement of what is needed, so someone on an iPhone knows to come back on an Android device rather than concluding the project is broken.
  • A retry for the timeout case — cheap to offer, and sometimes the runtime has recovered by the time the user taps it.

A large share of the people who open a link to a WebXR experience cannot run it — on iOS, none of them can. What share exactly depends on your audience's device mix, and if you have analytics you should measure it rather than repeat a number from a blog post. Either way it is not a temporary condition to ignore until the platform catches up; it is the environment, and it deserves designed behaviour rather than a dead end.

Clone this wiki locally