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.

Loading demo…

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 / platformimmersive-vrimmersive-arNotes
Meta Quest BrowserYesYesThe de facto reference for standalone headsets; hand tracking offered as an optional feature.
Chrome / Edge, AndroidDevice dependentYes (ARCore)AR runs on ARCore-capable phones; VR needs a supported or connected headset.
Chrome / Edge, WindowsWith a runtimeNoRequires SteamVR or another OpenXR runtime plus a connected headset.
Safari, visionOSYesLimitedSession support is present, but feature availability differs from the Quest browser — test rather than assume.
Safari, macOS / iOSNoNoNo WebXR device API. Fall back gracefully instead of nagging the visitor to switch browser.
Firefox, desktopNoNoWebVR 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.