Persistent Anchors in WebXR: Spatial Web Dev Guide [2026]
Why Persistent Anchors Are the Foundation of Spatial Computing
The promise of augmented reality goes beyond flashy overlays — it hinges on spatial memory. When a user places a virtual blueprint on a real desk and returns the next day expecting it to still be there, a persistent anchor is doing its job. Without this capability, every AR session resets from scratch, destroying the very continuity that makes spatial applications valuable.
The WebXR Anchors API (part of the WebXR Device API ecosystem) gives web developers a standardised path to bind virtual content to real-world coordinates — and, through the persistent anchors extension, to survive across sessions. This guide walks you through the complete implementation: from feature detection to cross-session anchor restoration, with production-ready code at every step.
Key Insight: Anchors vs. Reference Spaces
An XRReferenceSpace drifts — it is calibrated once per session relative to the device's starting position. An XRAnchor, by contrast, is continuously re-tracked by the underlying SLAM system (ARCore, ARKit, or OpenXR). Anchor transforms are corrected in real time as the platform refines its spatial map, making anchors the only reliable substrate for persistent spatial content.
Prerequisites
Before You Begin
- Browser: Chrome for Android 111+ or a WebXR-compatible browser with
anchorsfeature support - Device: ARCore-compatible Android device, or iOS with WebXR Viewer
- HTTPS: WebXR requires a secure context — use a valid TLS certificate or
localhost - Knowledge: Familiarity with the WebXR Device API basics (
XRSession,XRFrame,XRPose) - Optional: A bundler (Vite or Webpack) and Three.js or Babylon.js for scene rendering
Feature flag: In Chrome for Android, enable chrome://flags/#webxr-persistent-anchors if the flag is not yet stable in your target build.
Step 1: Detect Support and Initialise the XR Session
Always check feature support before requesting a session. The persistent anchors extension depends on two required features: 'anchors' and 'hit-test'. Wrapping the check in an async function allows you to surface a graceful fallback in unsupported browsers.
async function checkAnchorSupport() {
if (!navigator.xr) {
console.warn('WebXR not available');
return false;
}
const supported = await navigator.xr.isSessionSupported('immersive-ar');
if (!supported) {
console.warn('Immersive-AR not supported on this device');
return false;
}
return true;
}
async function startARSession() {
const ok = await checkAnchorSupport();
if (!ok) return;
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['anchors', 'hit-test'],
optionalFeatures: ['dom-overlay', 'light-estimation'],
domOverlay: { root: document.getElementById('overlay') }
});
session.addEventListener('end', onSessionEnd);
await initRenderer(session); // your WebGL / Three.js setup
session.requestAnimationFrame(onXRFrame);
}
Step 2: Set Up a Hit-Test Source
To place an anchor you first need a detected surface to place it on. Hit testing casts a ray from the device into the physical environment and returns intersection points on recognised surfaces. Request a hit-test source once the session's reference spaces are available:
let hitTestSource = null;
let localReferenceSpace = null;
async function initHitTest(session) {
// 'local' space for world-locked content
localReferenceSpace = await session.requestReferenceSpace('local');
// viewer space = ray emitted from screen centre
const viewerSpace = await session.requestReferenceSpace('viewer');
hitTestSource = await session.requestHitTestSource({
space: viewerSpace
});
}
// Re-initialise on visibility restore (e.g. after device sleep)
session.addEventListener('visibilitychange', async () => {
if (session.visibilityState === 'visible') {
await initHitTest(session);
}
});
Step 3: Create an Anchor on User Tap
In each animation frame, query the current hit-test results and, on a user interaction (tap or controller trigger), call XRHitTestResult.createAnchor(). This is the preferred method because it ties the anchor directly to the surface geometry the hit test detected, giving the platform richer tracking information than a freeform pose anchor.
const anchors = new Map(); // localKey -> { anchor, mesh }
let userTapped = false;
document.addEventListener('click', () => { userTapped = true; });
async function onXRFrame(time, frame) {
const session = frame.session;
session.requestAnimationFrame(onXRFrame);
if (!hitTestSource) return;
const hitTestResults = frame.getHitTestResults(hitTestSource);
if (hitTestResults.length > 0 && userTapped) {
userTapped = false;
const hit = hitTestResults[0];
try {
// createAnchor returns Promise<XRAnchor>
const anchor = await hit.createAnchor();
const tempId = crypto.randomUUID();
anchors.set(tempId, { anchor, mesh: spawnMeshAt(anchor) });
console.log('Anchor created:', anchor);
await persistAnchor(anchor, tempId);
} catch (err) {
console.error('Failed to create anchor:', err);
}
}
// Update mesh transforms for every currently tracked anchor
for (const [id, { anchor, mesh }] of anchors) {
if (frame.trackedAnchors.has(anchor)) {
const pose = frame.getPose(anchor.anchorSpace, localReferenceSpace);
if (pose) {
mesh.matrix.fromArray(pose.transform.matrix);
}
}
}
}
Before integrating these snippets into your project, run them through the Code Formatter to enforce consistent indentation and style across your codebase.
Step 4: Persist and Restore Anchors Across Sessions
Call anchor.requestPersistentAnchor() to ask the platform to serialise and store the anchor. The method returns a UUID string that you cache in localStorage or IndexedDB. On the next session, pass the UUID to session.restorePersistentAnchor(uuid) to retrieve the live anchor object:
// --- Persist a single anchor ---
async function persistAnchor(anchor, localKey) {
try {
const uuid = await anchor.requestPersistentAnchor();
const stored = JSON.parse(localStorage.getItem('xr-anchors') || '{}');
stored[localKey] = uuid;
localStorage.setItem('xr-anchors', JSON.stringify(stored));
console.log(`Persisted with UUID: ${uuid}`);
return uuid;
} catch (err) {
// Device does not support persistent anchors — degrade gracefully
console.error('Persistence failed:', err);
return null;
}
}
// --- Restore persisted anchors at session start ---
async function restoreAnchors(session) {
const stored = JSON.parse(localStorage.getItem('xr-anchors') || '{}');
for (const [localKey, uuid] of Object.entries(stored)) {
try {
// restorePersistentAnchor returns Promise<XRAnchor>
const anchor = await session.restorePersistentAnchor(uuid);
anchors.set(localKey, { anchor, mesh: spawnMeshAt(anchor) });
console.log(`Restored anchor: ${uuid}`);
} catch (err) {
// Anchor invalidated (room remapped, device wiped, etc.)
console.warn(`Could not restore ${uuid} — purging from cache`);
delete stored[localKey];
}
}
localStorage.setItem('xr-anchors', JSON.stringify(stored));
}
Step 5: Delete Anchors Cleanly
Persistent anchors consume platform storage — always expose a removal path in your UI. Call both session.deletePersistentAnchor(uuid) and anchor.delete() to free resources at every layer of the stack:
async function removeAnchor(localKey) {
const entry = anchors.get(localKey);
if (!entry) return;
const { anchor, mesh } = entry;
mesh.parent?.remove(mesh);
const stored = JSON.parse(localStorage.getItem('xr-anchors') || '{}');
const uuid = stored[localKey];
if (uuid && xrSession) {
await xrSession.deletePersistentAnchor(uuid); // platform store
}
anchor.delete(); // in-session XRAnchor object
anchors.delete(localKey);
delete stored[localKey];
localStorage.setItem('xr-anchors', JSON.stringify(stored));
}
Verification and Expected Output
With the implementation complete, validate against these checkpoints on a physical device:
- Console on anchor creation: You should see
Anchor created: XRAnchor { anchorSpace: XRSpace }immediately followed byPersisted with UUID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. - Tracked set size: In the frame loop,
console.log('Tracked:', frame.trackedAnchors.size)should be at least1after placement. - Cross-session restore: Close and reopen the AR session. You should see
Restored anchor: [uuid]for each stored UUID, with all virtual objects reappearing at their original physical positions. - localStorage inspection: In DevTools, check Application → Local Storage → your origin. The
xr-anchorskey should hold a JSON map of local IDs to platform UUIDs.
// Quick smoke test — dump tracked anchors each frame
function debugAnchors(frame) {
console.group(`trackedAnchors count: ${frame.trackedAnchors.size}`);
for (const anchor of frame.trackedAnchors) {
console.log('anchorSpace:', anchor.anchorSpace);
}
console.groupEnd();
}
Troubleshooting: Top 3 Issues
1. anchor.requestPersistentAnchor is not a function
The browser or device does not yet expose the persistent anchors extension. Verify you are on Chrome 111+ for Android and that chrome://flags/#webxr-persistent-anchors is enabled. Also confirm 'anchors' is in requiredFeatures when calling navigator.xr.requestSession(). Fallback strategy: cache the anchor's pose matrix in localStorage and reconstruct content from a local-floor reference space on the next session — less accurate but functional on older builds.
2. Restored Anchor Appears at the Wrong Position
The most common cause is a reference space mismatch. Persistent anchors are stored relative to the room's feature map. If the user scans a different part of the environment or the SLAM map resets after a device reboot, anchor coordinates remain technically valid but are spatially misaligned. Always include a re-calibrate UI that lets users delete and re-place anchors. Also ensure you request the same reference space type ('local' vs. 'local-floor') on restore as was used during original placement.
3. frame.trackedAnchors Does Not Include the Anchor
Anchors temporarily drop from the tracked set when the camera loses sight of the surface features that define the anchor region — this is expected SLAM behaviour. Do not delete the anchor on dropout. Instead, keep rendering it at its last known pose and let it re-enter trackedAnchors naturally as tracking recovers. Maintain a lastKnownPose map per anchor key to preserve visual continuity during brief tracking gaps.
What's Next: Cloud Anchors and Multi-User Spaces
Device-local persistent anchors are the foundation, but production spatial applications typically demand more:
- Cloud Anchors (ARCore / ARKit): Google's ARCore Cloud Anchors and Apple's ARKit allow sharing anchor UUIDs across devices. The same physical location becomes accessible to multiple users simultaneously, enabling true collaborative AR without a dedicated server.
- Geospatial Anchors: Google's ARCore Geospatial API binds anchors to GPS + VPS (Visual Positioning Service) coordinates. Anchors survive not just session reloads but device changes — any user who reaches that GPS coordinate sees the shared content.
- OpenXR Spatial Anchors: As WebXR continues aligning with the OpenXR 1.1 spatial anchor extension, native-quality persistence is coming to headsets such as Meta Quest 3 and HoloLens 2 directly via the browser, without a native app wrapper.
- Spatial Content Indexing: Build a JSON manifest mapping anchor UUIDs to content descriptors (model URLs, metadata, access permissions) so your app can batch-restore all anchors silently on session start without any explicit user interaction.
The spatial web is not science fiction — it is a set of well-specified APIs ready for developers to build with today. Start with a single persistent anchor, and you will have the mental model for everything that follows.
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.
Related Deep-Dives
The Augmented Reality Revolution: How AR Is Merging Digital and Physical Worlds in 2026
A broad survey of how AR platforms are converging physical and digital layers across consumer and enterprise applications in 2026.
System ArchitectureApple visionOS 26.4 + CloudXR 6 Integration: What Developers Need to Know
Technical breakdown of the visionOS 26.4 and CloudXR 6 integration, covering streaming latency, spatial audio, and developer API changes.
Developer ToolsWasm Component Model: Building Polyglot Systems Deep Dive 2026
How the WebAssembly Component Model enables language-agnostic module composition, and what it means for spatial and edge web runtimes.