Anchors
An anchor is a promise from the runtime that it will keep correcting a pose as it learns more about the room — which means the pose you read this frame is not the pose you read last frame, and that is the entire point.
The same six objects, half of them anchored
Every couple of seconds the runtime revises its model of the room. Anchored objects are revised along with it and stay on their rings. Unanchored ones keep the coordinates they were given and jump away — the tether shows how far the estimate has moved since.
This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.
The coordinate system is a guess, and it gets revised
A tracking runtime does not know where anything is. It builds an estimate from camera frames and inertial data, and that estimate is continuously refined. When you walk to the far side of a room and back, the runtime recognises features it has seen before and closes the loop — and closing a loop means admitting that the coordinates it was reporting a minute ago were off by a few centimetres, and re-fitting everything.
That re-fit is invisible if all your content is anchored. It is extremely visible if it is not. Content placed at a literal coordinate stays at that coordinate while the frame it was expressed in shifts underneath it, so the mug you set on the table is now floating beside the table, and it got there in one step rather than by sliding.
An anchor is how you opt out of that. You hand the runtime a pose and it hands you back an object whose pose it will keep updating so that it continues to describe the same physical point. The pose changes; the physical meaning does not. Reading it once and caching the result throws away the only thing you asked for.
Creating an anchor, and reading it every frame
There are two ways to create one, and they are not equivalent. Creating from a hit test result is better whenever you have one, because the runtime knows which trackable the result came from and can tie the anchor to that surface rather than to a bare point in space.
Both return a promise that may take several frames to settle. Neither can be awaited inside the frame callback.
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: ['hit-test'],
optionalFeatures: ['anchors'],
});
const localSpace = await session.requestReferenceSpace('local-floor');
// Anchors may be refused even when hit testing was granted. Degrade to
// unanchored placement rather than failing the whole session.
const anchorsEnabled = session.enabledFeatures?.includes('anchors') ?? false;
const tracked = new Map(); // XRAnchor -> your scene object
function placeAt(hitResult, frame, object) {
if (!anchorsEnabled || !hitResult.createAnchor) {
// Fallback: fixed coordinates. Correct until the user walks around.
const pose = hitResult.getPose(localSpace);
object.matrix.fromArray(pose.transform.matrix);
return;
}
// Preferred: the runtime ties this to the trackable the hit came from.
hitResult.createAnchor().then((anchor) => {
tracked.set(anchor, object);
}).catch(() => {
// Budget exhausted, or tracking too poor to anchor right now.
});
// The other route, when there is no hit test result to hand:
// frame.createAnchor(new XRRigidTransform(position, orientation), localSpace)
}
function onFrame(time, frame) {
for (const [anchor, object] of tracked) {
// An anchor missing from trackedAnchors is no longer being maintained.
// Hide the object; do not keep drawing it at a stale pose.
if (!frame.trackedAnchors.has(anchor)) {
object.visible = false;
continue;
}
// Re-read every frame. This is the whole contract -- the pose is
// expected to change as the runtime refines its model.
const pose = frame.getPose(anchor.anchorSpace, localSpace);
if (!pose) { object.visible = false; continue; }
object.visible = true;
object.matrix.fromArray(pose.transform.matrix);
}
}
session.addEventListener('end', () => {
// Anchors hold runtime tracking resources. Release them.
for (const anchor of tracked.keys()) anchor.delete();
tracked.clear();
});object.matrix.fromArray only takes effect in three.js when matrixAutoUpdate is off for that object — otherwise the next render overwrites it from position and quaternion. This is the most common reason an anchor "does nothing".
The pieces, and what each one is responsible for
Five names cover the whole module. Most anchor bugs are a misunderstanding of one of them rather than a mistake in the code.
- XRAnchor
- The handle. It carries no pose of its own that you read directly — it exposes a space, and you resolve that space against your reference space each frame. Holding the object is not the same as tracking it.
- anchorSpace
- An XRSpace whose origin is the anchored point. Pass it to frame.getPose along with the reference space your scene is built in. It returns null while tracking for that anchor is temporarily unavailable, which is normal and recoverable.
- XRFrame.trackedAnchors
- The set of anchors the runtime is still maintaining this frame. Anchors can leave the set — the area was forgotten, or the runtime gave up on it. Membership is the only reliable signal that an anchor is still meaningful.
- XRAnchor.delete()
- Releases the anchor and the tracking work behind it. Platforms cap how many anchors a session may hold, and hitting the cap makes further createAnchor calls reject.
- requestPersistentHandle()
- Returns a UUID you can store and pass to session.restorePersistentAnchor() in a later session, so a placement survives the app being closed. Support is narrower than for plain anchors — treat it as an enhancement, never as the storage layer.
A jump is not a drift, and the difference is diagnostic
When people describe anchor problems they usually say the content "drifts". That word points at the wrong cause. Smooth, continuous sliding is a tracking quality problem: poor lighting, a featureless white wall, a device that has lost its visual reference and is coasting on inertial data. No amount of anchoring fixes it, because the runtime itself does not know where anything is.
A discrete jump is the opposite situation. The runtime just figured something out and corrected its model, and your content did not come along. That is an anchoring problem, and it is fixable in a few lines. Watching whether the error arrives smoothly or in one step tells you which of the two you have before you change any code.
There is a third case worth naming: content that is anchored but still wrong, because the pose is read once at creation and cached. This looks exactly like the unanchored case, which is why it survives so long in a codebase — the anchor is there, the API calls succeed, and the bug is a missing read in the frame loop.
What the demo above is doing
Six objects sit on two surfaces. The green rings mark their true physical positions — the point an anchor promises to keep describing. In the default mixed mode every other object is anchored, so both behaviours are on screen at once and can be compared without toggling anything.
Every couple of seconds the runtime revises its model. The rings pulse to mark the moment, because that discreteness is the property worth seeing: the error does not creep in, it arrives all at once. Anchored objects are re-posed by the runtime and stay inside their rings. Unanchored ones keep the coordinates they were given and step away from theirs.
The tethers exist because between two corrections the picture is completely still, and a still picture makes unanchored placement look fine. The line is the accumulated error, drawn so it stays visible while nothing is moving. Set the correction size to zero and the accumulated error clears — that is the world where testing at your desk convinces you the code is correct.
Feature availability
anchors is an optional feature and is granted separately from hit-test. Persistent anchors are narrower still, and their absence must not break placement.
| Platform | anchors | From hit test result | Persistent handles |
|---|---|---|---|
| Android Chrome (ARCore) | Yes | Yes | Partial |
| Meta Quest 3 / Pro browser | Yes | Yes | Partial |
| visionOS Safari | Partial | Partial | No |
| iOS Safari (iPhone) | No | No | No |
| Desktop browsers | No | No | No |
Checked 2026-09. Read session.enabledFeatures at runtime rather than trusting any table, including this one — the request can succeed with a feature silently absent.
Mistakes that cost the most time
The first two are the same bug wearing different clothes, and both pass every test you run while standing still.
- Reading the anchor pose once
- Creating an anchor and copying its pose at creation time gives you exactly the behaviour you were trying to avoid. The pose must be re-read from anchorSpace every frame; that it changes is the feature, not a glitch.
- Ignoring trackedAnchors
- An anchor can stop being maintained. If you only check for a null pose you will keep a dead anchor in your map forever, retrying every frame and drawing nothing.
- Anchoring everything
- Each anchor costs the runtime real tracking work and platforms cap the total. Anchor the things that must hold a physical position; parent the decorations to them.
- Never calling delete()
- Anchors are not garbage collected on your behalf. Long sessions that place and discard content leak them until createAnchor starts rejecting for no visible reason.
- Assuming anchors were granted
- requestSession resolves even when an optional feature was refused. Check session.enabledFeatures and keep an unanchored path — a session with no anchors is still worth running.
- Treating a persistent handle as storage
- The UUID is meaningful only to that runtime on that device. It is not a location, it does not sync, and restorePersistentAnchor can fail because the room no longer looks the way it did.
Further reading
The anchors module is short and worth reading end to end. The hit test module is the natural companion, since most anchors in practice are created from a hit test result.
- W3C — WebXR Anchors Module — createAnchor, anchorSpace, trackedAnchors, and the persistence extension.
- MDN — XRAnchor — The interface, its space, and the deletion semantics.
- MDN — XRFrame.createAnchor() — The frame-relative route, for when there is no hit test result to build on.
- AR hit testing — Finding the surface in the first place, and why the result carries an orientation.
- WebXR sessions — Optional features, enabledFeatures, and the reference spaces poses resolve into.
- Plane detection — The other way to learn about surfaces, and why a merge kills a plane pose.
- Environment blend modes — Whether the user can see the room the anchor is pinned to, and what that changes.