WebXR in the Browser: Shipping VR and AR Scenes That Degrade Gracefully

Do you know what your scene does the moment navigator.xr comes back undefined? If you have not walked that path on purpose, most of your traffic is meeting whatever your renderer happens to do by accident.
WebXR is unusual among browser APIs because three genuinely different hardware categories funnel through a single entry point. A Quest 3, an Android phone held at arm's length, and a Windows laptop with no sensors at all arrive at the same navigator.xr.requestSession() call, and the engineering that matters is in what you hand back to each of them.
WebXR defines three session modes: immersive-vr, immersive-ar, and inline. A graceful build keeps one scene graph and swaps the pose source, input mapping, and reference space per mode instead of branching the renderer.
One Entry Point, Three Different Contracts
The specification defines three session modes, and each one hands you a different contract. immersive-vr takes over the display and gives you a stereo viewer pose, immersive-ar composites your scene over a camera feed or passthrough, and inline renders into an ordinary canvas in the page with a single monoscopic view.
Inline is the mode most teams skip, and it is the one carrying your entire fallback story. It needs no permission prompt, no headset, and no user gesture in most implementations, which makes it the natural home for desktop visitors and for mobile browsers that never expose XR at all.
Keep in mind that support is reported per mode, not per browser. Chrome on an ARCore-capable Android device typically reports immersive-ar as supported and immersive-vr as not; Safari on visionOS reports immersive-vr; Safari on an iPhone exposes no navigator.xr at all, which means every iOS visitor is an inline visitor unless you route them somewhere else entirely.
| Session mode | Typical device | Views per frame | What degrades away |
|---|---|---|---|
| immersive-vr | Quest 3, Quest Pro, PSVR2, visionOS Safari | 2 (stereo) | Room-scale bounds, tracked controllers, hand joints |
| immersive-ar | ARCore Android in Chrome | 1 (mono, camera-composited) | Hit-test surfaces, plane detection, light estimation |
| inline | Any browser with WebGL | 1 (mono) | All sensing; the pose comes from your own camera controls |
Read that table as a subtraction ladder rather than three products. Each row down removes a capability, and your job is to decide what replaces it — a synthetic floor, a fixed placement distance, an orbit control — before the device tells you it is missing.
Build The Rig, Not The Camera
The single design decision that determines whether one codebase can serve all three modes is where you write the pose. Write it onto the camera and you have hard-coded VR; write it onto a parent rig node and every mode becomes a different way of driving the same transform.
In an immersive session, XRFrame.getViewerPose(referenceSpace) hands back one or two XRView objects, each carrying its own projection matrix and eye transform. You copy those into per-view cameras parented to the rig, then draw once per view into the correct viewport of the XRWebGLLayer framebuffer.
In inline mode, nothing hands you a pose, so the rig is driven by orbit controls, a keyboard controller, or a scripted camera path. Everything below the rig in the scene graph never learns which of those happened, and that ignorance is the whole point.
This is the same separation that keeps any browser renderer maintainable — the argument shows up again in choosing between Canvas 2D and WebGL, where the drawing surface changes but the scene description should not. Once the rig abstraction exists, a non-immersive viewer watching the same simulation is nearly free, which is the foundation of browser spectator mode.
Drive a parent rig node with the XR pose rather than the camera itself. Immersive sessions write viewer pose into the rig, inline sessions write orbit or keyboard input into the same node, and the scene below stays identical.
Reference Spaces Are Where Most Fallbacks Break
A session does nothing useful until you request a reference space, and the space you receive decides where the floor is. The five defined types are not interchangeable, and a device that grants one may refuse another in the same session.
Here is what each one actually gives you:
- viewer. The origin tracks the viewer's head and is always available. Use it for hit-test ray origins and for anything that must stay locked to the face.
- local. The origin sits near the viewer's position at session start with no floor guarantee. This is the safe baseline for seated content.
- local-floor. Identical to local, except the origin is placed at the physical floor. Request this for anything the user stands up inside.
- bounded-floor. Adds a polygon describing the safe walkable area. Only room-scale headsets grant it, so treat it as a bonus rather than a dependency.
- unbounded. Tracking maintained across an arbitrarily large area. Rare in practice, and mostly relevant on passthrough hardware doing world-scale work.
The graceful pattern is a request cascade: attempt bounded-floor, fall back to local-floor, fall back to local, and offset the rig by an assumed eye height — 1.6 meters is the common figure — whenever the floor is unknown. Remember that a seated user in a local space who gets silently buried or floated is the most-filed WebXR bug there is.
Feature negotiation follows the same shape. A missing entry in requiredFeatures rejects requestSession() outright, while a failed entry in optionalFeatures leaves the session running without it, so put only genuine hard dependencies in the required list and capability-check the rest at runtime.
Input Is The Fork You Cannot Avoid
Pose and rendering generalize across modes with real elegance. Input does not, because a 6DoF controller, a screen tap, and a mouse produce fundamentally different intent signals from fundamentally different geometry.
WebXR normalizes part of this through XRInputSource.targetRayMode, which reports one of four values:
- tracked-pointer. A 6DoF controller or a tracked hand, where both ray origin and orientation are real measurements. This is the only mode where a laser pointer metaphor is honest.
- screen. A touch on a handheld AR device, where the ray is computed from the touch point through the camera frustum. The source is transient and exists only for the duration of that touch.
- gaze. A 3DoF device with no controllers, where the ray originates at the viewer. Selection comes from a button press or a dwell timer.
- transient-pointer. Pinch-style targeting where the input source appears at the moment of the gesture and disappears immediately afterward. Any code that assumes a persistent input source will drop these entirely.
Underneath, each input source may expose a gamepad object with the standard button and axis layout, which means the abstraction you already built for reading controllers through the Gamepad API absorbs XR controllers with modest changes. On the inline path, that same intent layer accepts pointer events and the gesture handling covered in building mobile touch controls that survive the browser.
The rule that keeps this maintainable is strict: gameplay code consumes intents such as select, grab, move, and teleport, and never reads a button index or a touch identifier directly. Buffering those intents the way an input buffer smooths browser game input also hides the latency difference between a controller press and a touch event.
WebXR reports intent through XRInputSource.targetRayMode: tracked-pointer, screen, gaze, or transient-pointer. Map all four onto one intent layer so gameplay code reads select or grab, never a raw button index.
The Render Loop Changes Owner
Once an immersive session starts, window.requestAnimationFrame stops driving your scene. The session owns the loop through XRSession.requestAnimationFrame, and it fires at the device refresh rate rather than the page's.
That handoff is a reliable source of double-stepping bugs, where the page loop keeps advancing simulation while the session loop advances it again. Cancel the page loop explicitly when the session starts and restart it inside the session's end handler, rather than trusting the two to stay out of each other's way.
The underlying instability has the same cure it has everywhere else — decouple simulation from presentation using a fixed timestep with an accumulator, so a 72Hz headset, a 120Hz headset, and a 60Hz desktop all produce identical physics from identical inputs.
Also be aware that a session can enter a visible-blurred state when the user opens a system menu. Your callback keeps firing while the scene is not fully presented, so check XRSession.visibilityState before doing expensive work, and pause audio and timers when it reports hidden.
In an immersive session, XRSession.requestAnimationFrame replaces window.requestAnimationFrame and runs at the device refresh rate. Cancel the page loop on session start or your simulation steps twice per frame.
Frame Budget Math For Three Devices At Once
A desktop scene at 60Hz gets 16.7 milliseconds per frame. A Quest 3 at its default 90Hz gets 11.1 milliseconds, at 120Hz it gets 8.3, and it draws the scene twice inside that window.
Stereo rendering roughly doubles fragment work and, without help, doubles draw call submission as well. Two mitigations carry most of the load: XRWebGLLayer.fixedFoveationLevel, which lowers shading rate toward the periphery, and multiview rendering through OVR_multiview2, which submits a single draw call for both eyes instead of two.
You can buy further headroom with framebufferScaleFactor, which scales the XR framebuffer against the device's recommended resolution. Dropping from 1.0 to 0.8 reduces pixel count by roughly 36 percent, which is often the difference between a stable 90Hz and visible reprojection.
Asset budget is the other half of the problem. A scene that streams comfortably over a desktop connection can stall a standalone headset's decode path, which is why the progressive techniques in streaming assets into a browser game matter more in XR, and why moving decode and geometry work off the main thread with OffscreenCanvas and workers pays for itself quickly.
Budget headset-first. Target the 11.1 millisecond frame, then let desktop inherit the same scene with a higher framebuffer scale and richer post-processing.
Tuning a desktop-first scene down to headset constraints almost always costs more engineering time than tuning a headset-first scene up.
Feature Detection That Does Not Lie
navigator.xr.isSessionSupported(mode) returns a promise, and it can resolve false on hardware that would in fact support the mode once permissions are granted. Treat the answer as a strong hint and still wrap requestSession() in a rejection handler that falls back rather than throwing into an empty canvas.
Three further gates catch teams late in the schedule. WebXR requires a secure context, so a plain LAN IP over HTTP gets you nothing; immersive sessions require a user activation, so you cannot auto-enter on page load; and navigator.xr may be absent entirely, which means every access needs a guard rather than a try block wrapped around your bootstrap.
What's more, hardware can appear and disappear mid-page. The devicechange event on navigator.xr fires when a headset is connected or removed, which is your cue to re-run the capability check and update the entry button's state.
That button is the honest surface for all of this. Render one control whose label and enabled state derive from the capability check, rather than three buttons that each promise something the device may refuse.
isSessionSupported() returns a promise and can resolve false before permissions are granted, so always wrap requestSession() in a rejection handler. WebXR also requires HTTPS and a user gesture for immersive modes.
Handheld AR Degrades Differently Than VR
An immersive-ar session on a phone is a monoscopic camera pass with a single view, which makes it structurally closer to your inline path than to a headset. The real differences live in the world-sensing features, and each one needs its own named fallback.
The hit-test feature gives you a genuine surface intersection through an XRHitTestSource; without it, place objects on a synthetic ground plane a fixed distance in front of the camera and let the user nudge them. Plane detection, anchors, depth sensing, and light estimation all degrade the same way, with measured world data replaced by a defensible constant.
The dom-overlay feature deserves separate attention because it is how ordinary HTML UI stays on screen during an AR session. Request it as an optional feature bound to a specific root element, and keep that same component rendering as a normal page overlay when no session ever starts.
For iOS visitors, the honest fallback is a different technology rather than a degraded WebXR path. Detect the absence of navigator.xr, serve an in-page 3D viewer with a USDZ file for Quick Look, and stop presenting an AR button that cannot work.
How Do You Know Your Fallback Actually Works?
Testing degradation by hand across real hardware does not scale, and the browser emulator covers only part of the matrix. The workable approach is a forced-mode switch inside your own bootstrap, ahead of any capability detection.
Build a query parameter that pins the path — ?xr=vr, ?xr=ar, ?xr=inline, ?xr=off — and have it short-circuit the detection layer entirely. That gives you deterministic startup paths a headless browser can screenshot in CI, on every commit, without a headset in the room.
The matrix worth covering is smaller than it looks:
- Headset with controllers and room-scale. Confirms bounded-floor geometry, stereo views, and tracked-pointer rays. This is the path most teams already test.
- Headset with hand tracking only. Confirms your intent layer survives when no gamepad object exists on the input source.
- Handheld AR with hit-test granted. Confirms single-view rendering, screen-mode targeting, and DOM overlay layout at phone aspect ratios.
- Handheld AR with hit-test denied. Confirms the synthetic ground plane places objects somewhere sensible instead of at the world origin.
- Inline desktop. Confirms your own camera controls drive the rig and that no XR-only code path throws.
- No WebXR at all. Confirms the entry button never renders and the iOS viewer path takes over cleanly.
Six deterministic paths and a screenshot diff catch nearly everything that ships broken. The remaining failures are almost always performance rather than logic, which is where the frame budget work above earns its keep.
Add a URL parameter such as ?xr=inline or ?xr=off that short-circuits capability detection. It gives you deterministic startup paths you can screenshot in CI without owning every headset in the matrix.
Frequently Asked Questions
Which reference space should I request for a standing WebXR scene?
Request bounded-floor first, then local-floor, then local. When only local is granted there is no floor guarantee, so offset the rig by an assumed eye height near 1.6 meters or the user starts underground.
Does iOS Safari support WebXR on iPhone?
iPhone Safari does not expose navigator.xr, so an iOS visitor lands on your inline path. The usual substitute is an in-page 3D viewer plus a USDZ file handed off to Quick Look for the AR view.
What is the frame budget for a Quest 3 WebXR scene?
Roughly 11.1 milliseconds at the default 90Hz and 8.3 at 120Hz, with the scene drawn once per eye. Use fixedFoveationLevel and OVR_multiview2 so stereo does not double both shading and draw calls.
Should XR features go in requiredFeatures or optionalFeatures?
A missing requiredFeature rejects requestSession outright, while a failed optionalFeature leaves the session running. List only what the experience cannot run without, and capability-check everything else at runtime.
How do I handle a WebXR session that loses focus?
Check XRSession.visibilityState every frame. On visible-blurred the callback still fires while the user sits in a system menu, so skip expensive rendering; on hidden, pause audio, timers, and network sync.
Where To Take This Next
If you are scoping a WebXR feature and the plan currently reads as one build for headsets and another for everyone else, the rig-and-intent split above is the change that collapses it back to one codebase. Start by moving the pose write off the camera, then map all four target ray modes onto a single intent enum before you write any gameplay against them.
From there, the performance work is ordinary browser rendering work under tighter constraints — the same fixed timestep, the same streaming discipline, the same worker offloading, applied at 11 milliseconds instead of 16. The rest of the browser engineering guides on this site cover those foundations in depth, and every one of them applies unchanged the moment a headset joins your session.


