WebXR sessions
A WebXR session is the handshake that hands your page a headset: one call takes over both displays, and the reference space you pick decides where the floor is.
Session lifecycle, live
The same scene, driven two ways. Switch the reference space and watch the origin marker move relative to the floor; adjust the simulated eye separation to see what the compositor is doing with two projections.
This demo needs WebGL, which your browser did not provide. Everything below explains the same material without it.
What a session actually is
Outside of XR your page draws into a canvas and the browser composites that canvas into the document. An immersive session inverts the relationship. The page stops owning the frame: it hands finished frames to the device compositor, which reprojects and displays them on the headset panels at whatever rate the hardware runs — 72, 90, 120 Hz — largely independently of how fast your JavaScript manages to run.
Two things arrive with the session, and both matter more than the pixels do. The first is a frame loop driven by the device instead of by the monitor. The second is a coordinate system: a reference space that establishes where the origin is, which way is up, and — depending on which one you asked for — where the physical floor sits.
An immersive session is also exclusive. One per device at a time; a second requestSession while one is live will reject. That is deliberate rather than a limitation: two pages fighting over a headset is a safety problem, not merely a rendering one.
Asking for a session
Three things have to be true before a headset will hand you a frame: the browser exposes navigator.xr, the page is in a secure context, and the call originates from a user gesture. The last one catches nearly everyone the first time.
// 1. Feature detection. navigator.xr is undefined in browsers without WebXR,
// and isSessionSupported() rejects outside a secure context (HTTPS or localhost).
const xr = navigator.xr;
const supported = xr ? await xr.isSessionSupported('immersive-vr') : false;
enterButton.disabled = !supported;
// 2. requestSession() must run inside a user gesture. Calling it on page load,
// from a timer, or after an await that outlives the gesture rejects with a
// SecurityError -- the transient activation is already spent by then.
enterButton.addEventListener('click', async () => {
const session = await xr.requestSession('immersive-vr', {
// Refuse the session outright if the floor cannot be located.
requiredFeatures: ['local-floor'],
// Nice to have. A device without these still gets a session.
optionalFeatures: ['bounded-floor', 'hand-tracking', 'layers'],
});
// 3. three.js takes over from here: it installs its own frame loop,
// builds one camera per view, and drives the XR compositor.
await renderer.xr.setSession(session);
});requiredFeatures is a promise you make to yourself. Anything listed there that the device cannot provide makes requestSession reject outright, so you never end up half-initialised inside a session that is missing something your scene assumes.
Reference spaces: deciding where zero is
requestReferenceSpace() is where most confusion about WebXR coordinates originates. The type you ask for changes what the origin means, and choosing wrong produces the two classic bugs: a scene buried under the floor, or one hovering near the ceiling.
- viewer
- The origin tracks the head, always. Useful for head-locked UI and for checking that tracking works at all — never for placing world content, which would then follow the user around the room.
- local
- The origin sits roughly where the viewer was when the session started. Stable for seated content, but it promises nothing about floor height: y = 0 is wherever the head happened to be, not the ground.
- local-floor
- Like local, with the origin dropped to the physical floor, so y = 0 is the ground and 1.6 m is roughly adult eye height. This is the sane default for anything the user experiences standing up.
- bounded-floor
- local-floor plus a boundsGeometry polygon describing the area the user can safely walk within. Ask for it when you want physical movement, and fall back to local-floor when the runtime does not grant it.
- unbounded
- For experiences that roam beyond a single room. The runtime may quietly adjust the origin over time to keep tracking accurate, so a position you recorded ten minutes ago is not necessarily where you left it.
The frame loop changes hands
Once a session is running, requestAnimationFrame is the wrong loop. It is bound to the page display rather than the headset, and inside an immersive session it may be throttled or stop altogether. The session brings its own loop, and each frame arrives with an XRFrame that you must use to query poses.
// Without XR you would call requestAnimationFrame(tick) yourself.
// three.js swaps the loop when a session starts: setAnimationLoop routes
// through session.requestAnimationFrame() automatically.
renderer.xr.enabled = true;
renderer.setAnimationLoop((time, frame) => {
// `frame` is undefined outside a session and an XRFrame inside one.
if (frame) {
const pose = frame.getViewerPose(referenceSpace);
// pose is null whenever tracking drops -- headset lifted off the head,
// sensors covered, user stepping out of the play space. Skip the frame.
// Never reuse the last known pose as though it were current.
if (pose) {
for (const view of pose.views) {
// One view per eye. Two on every shipping headset, but the spec
// permits other counts, so iterate instead of indexing [0] and [1].
}
}
}
renderer.render(scene, camera);
});Forgetting renderer.xr.enabled = true is the quietest failure in WebXR. The session starts, the headset shows your scene, and it renders monoscopically from the wrong camera — everything looks almost right, which is what makes it expensive to find.
What the demo above is doing
The demo runs one scene through both paths. In a desktop browser there is no session at all: an orbiting camera stands in for the headset, and a simulated pair of eyes shows what stereo separation does to the two projections. The reference-space selector moves the origin marker rather than the room, which is the honest way to show what actually changes — the room stays where it is, zero moves.
On a device that reports immersive-vr support, an Enter VR button appears and the very same scene graph is handed to a real session. Nothing gets rebuilt; only the source of the camera pose changes. That is the entire reason the demo is written against an internal pose abstraction rather than against navigator.xr directly — the desktop path is a first-class path, not a stub.
Where this runs
Support is not one flag. A browser can implement WebXR fully and still report false for a session mode simply because no device is attached, so always branch on isSessionSupported() for the specific mode you need rather than on the presence of navigator.xr.
| Browser / platform | immersive-vr | immersive-ar | Notes |
|---|---|---|---|
| Meta Quest Browser | Yes | Yes | The de facto reference for standalone headsets; hand tracking offered as an optional feature. |
| Chrome / Edge, Android | Device dependent | Yes (ARCore) | AR runs on ARCore-capable phones; VR needs a supported or connected headset. |
| Chrome / Edge, Windows | With a runtime | No | Requires SteamVR or another OpenXR runtime plus a connected headset. |
| Safari, visionOS | Yes | Limited | Session support is present, but feature availability differs from the Quest browser — test rather than assume. |
| Safari, macOS / iOS | No | No | No WebXR device API. Fall back gracefully instead of nagging the visitor to switch browser. |
| Firefox, desktop | No | No | WebVR was removed and WebXR has not shipped enabled on desktop. |
Checked 2026-09. Browser and runtime releases move this table — verify against the MDN compatibility data before depending on any row.
Mistakes that cost the most time
None of these produce a clear error message, which is exactly why they are worth knowing before you meet them.
- Requesting a session outside a gesture
- Anything that lets the transient activation expire — a timer, an await on a fetch that runs first, a promise chain resolving on a later task — turns the call into a SecurityError. Request the session first and load assets afterwards.
- Assuming exactly two views
- pose.views is a list because the specification says it may hold other counts. Index it as [0] and [1] and a device with a different optical arrangement renders incorrectly.
- Treating a null pose as fatal
- Tracking drops routinely and usually recovers within a frame or two. Re-present the previous frame or skip; do not tear the session down.
- Leaving the desktop loop running
- If a non-XR requestAnimationFrame loop is still alive during a session you render the scene twice per frame, then spend an afternoon wondering where the framerate went.
- Never handling the end event
- Sessions end for reasons unrelated to your button: the user removes the headset, the runtime reclaims the device, the tab goes away. Listen for 'end' and restore the desktop loop there rather than inside your own exit handler.
Further reading
The specification is more readable than its reputation suggests, and it is the only source that stays correct as implementations move.
- MDN — WebXR Device API — Reference documentation plus the compatibility tables behind the matrix above.
- W3C — WebXR Device API specification — Normative definitions of session modes, reference spaces and the frame loop.
- three.js — WebXR manual — How renderer.xr wires a session into an existing scene.
- VR controllers — The next thing a session gives you: input sources, pointer rays and haptics.
- Stereo rendering — What the compositor does with the two views this page only simulates.