# A performance budget for WebXR on Quest 3

> How much time a frame has in the Meta Quest Browser, how to find out whether the CPU or the GPU is the limit, and what to change in a React Three Fiber scene.

Michal Takáč, 2 October 2026 · Guides
Source: https://graspable.dev/blog/webxr-performance-budget-quest-3

A WebXR scene that runs smoothly on a laptop can stutter on a headset. The headset has a mobile chip, draws everything twice, and has to do it far more often per second than a film. A missed frame in a headset is not a cosmetic problem. The world visibly judders, and people feel unwell.

This guide gives you the one number that is fixed, the frame time, and a method to find the rest for your own scene. It does not give triangle or draw call limits, because honest ones depend on your materials, your lights and your code. You have to measure them.

## The budget is time

The headset shows a new image at a fixed rate. Your frame has to be finished before the next one is due. The time per frame is 1000 ms divided by the rate:

- 72 frames per second: 13.9 ms
- 90 frames per second: 11.1 ms
- 120 frames per second: 8.3 ms

Meta's documentation says a WebXR session in the Meta Quest Browser runs at 72 frames per second by default on Quest headsets, and that the app can ask for another rate. The rates a device offers are in `session.supportedFrameRates`, and `session.updateTargetFrameRate(rate)` asks for one. See [WebXR app framerate control](https://developers.meta.com/horizon/documentation/web/webxr-frames/).

That time is shared by everything: your JavaScript, React, physics, three.js preparing the scene, and the graphics chip drawing it for two eyes. The browser and the system need some of it too. So aim to finish well inside the budget, not at its edge.

A higher rate is not always better. A scene that holds 72 without missing a frame is more comfortable than one that asks for 90 and misses often.

## Why a headset is harder than a laptop

- **Two views.** Each eye gets its own image. three.js 0.170 draws the scene once per eye, so every draw call happens twice.
- **Many pixels.** The two images together have far more pixels than a typical laptop window, and each pixel runs your fragment shader.
- **A mobile chip.** It has less power and a tighter heat limit than a desktop graphics card. When it gets hot it slows down.

## First find the limit: CPU or GPU

Changing things at random wastes time. Find out which side is over budget first. Meta's [performance workflow](https://developers.meta.com/horizon/documentation/web/webxr-perf-workflow/) describes the method, and it comes down to one experiment: make the picture much smaller and see whether the frame rate recovers.

- If it recovers, you are limited by pixels, which is the GPU drawing fragments. Reduce resolution, overdraw, shader cost and post-processing.
- If it does not, you are limited by the CPU or by geometry. Reduce draw calls, JavaScript work per frame, and vertex count.

With @react-three/xr the experiment is one option:

```js
import { createXRStore } from '@react-three/xr'

const store = createXRStore({
  // A temporary experiment: render at a fraction of the normal resolution.
  frameBufferScaling: 0.5,
})
```

Tools for looking closer:

- **OVR Metrics Tool** shows frame rate and CPU and GPU load inside the headset while your app runs.
- **Remote debugging.** Connect the headset by USB and open `chrome://inspect` on your computer to use the Chrome developer tools, including the Performance panel, on the page in the headset. See [debugging the browser remotely](https://developers.meta.com/horizon/documentation/web/browser-remote-debugging/).

## Count what you draw

three.js keeps counters for the last frame in [`renderer.info`](https://threejs.org/docs/#api/en/renderers/WebGLRenderer.info). Print them while the session runs:

```jsx
import { useEffect } from 'react'
import { useThree } from '@react-three/fiber'

function RenderStats() {
  const gl = useThree((state) => state.gl)
  useEffect(() => {
    const timer = setInterval(() => {
      const { calls, triangles } = gl.info.render
      console.log(`draw calls ${calls}, triangles ${triangles}, textures ${gl.info.memory.textures}`)
    }, 2000)
    return () => clearInterval(timer)
  }, [gl])
  return null
}
```

Put `<RenderStats />` inside the `Canvas`. In a session the numbers cover both eyes.

This is how you get your own budget. Run the scene on the headset, watch the frame rate, and add content until frames start to drop. The counts just before that point are the budget for this scene, with these materials. Write them down and check them when the scene grows.

## Reduce draw calls

Each mesh with its own material is at least one draw call per eye. Many small objects cost more than one large one with the same number of triangles.

- **Instancing.** Many copies of the same geometry and material can be one draw call with [`InstancedMesh`](https://threejs.org/docs/#api/en/objects/InstancedMesh).
- **Merging.** Static objects that share a material can be merged into one geometry.
- **Sharing materials.** Fewer distinct materials means fewer shader programs and state changes.

A thousand boxes as one draw call per eye:

```jsx
import { useLayoutEffect, useRef } from 'react'
import { Object3D } from 'three'

const COUNT = 1000
const helper = new Object3D()

function Boxes() {
  const mesh = useRef(null)
  useLayoutEffect(() => {
    for (let i = 0; i < COUNT; i++) {
      helper.position.set((i % 10) - 4.5, Math.floor(i / 100) * 0.3, -2 - (Math.floor(i / 10) % 10))
      helper.updateMatrix()
      mesh.current.setMatrixAt(i, helper.matrix)
    }
    mesh.current.instanceMatrix.needsUpdate = true
  }, [])
  return (
    <instancedMesh ref={mesh} args={[undefined, undefined, COUNT]}>
      <boxGeometry args={[0.1, 0.1, 0.1]} />
      <meshLambertMaterial color="orange" />
    </instancedMesh>
  )
}
```

## Reduce the cost per pixel

- **Use the cheapest material that looks right.** `meshBasicMaterial` does no lighting. `meshLambertMaterial` is cheaper than `meshStandardMaterial`. Baked lighting in a texture costs nothing at run time.
- **Few lights, and be careful with shadows.** Every shadow-casting light draws the scene again into a shadow map.
- **Avoid overdraw.** Large transparent surfaces stacked in front of each other make the chip draw the same pixel several times.
- **Be careful with post-processing.** Each full-screen pass draws every pixel of both eyes again. It also works against foveation, which Meta's documentation says only applies when rendering straight to the final frame buffer.
- **Use foveation.** Fixed foveated rendering draws the edges of each eye's image at a lower resolution, where the lens blurs them anyway. It is a value from 0 to 1 on the session's layer, [`XRWebGLLayer.fixedFoveation`](https://developer.mozilla.org/en-US/docs/Web/API/XRWebGLLayer/fixedFoveation). See Meta's page on [fixed foveated rendering](https://developers.meta.com/horizon/documentation/web/webxr-ffr/).

@react-three/xr exposes foveation, resolution and frame rate as store options:

```js
const store = createXRStore({
  foveation: 1,              // 0 is off, 1 is the strongest
  frameBufferScaling: 1,     // lower it if pixels are the limit
  frameRate: 'high',         // 'low', 'mid', 'high', or a function that picks from the supported rates
})
```

Its [performance page](https://pmndrs.github.io/xr/docs/advanced/performance) describes them.

## Reduce JavaScript work per frame

- **Do not set React state every frame.** Change objects directly inside `useFrame`. A state update re-renders components, and at 72 times a second that is a lot of work. See the React Three Fiber [pitfalls](https://r3f.docs.pmnd.rs/advanced/pitfalls).
- **Do not create objects every frame.** `new Vector3()` inside `useFrame` produces garbage, and the garbage collector pauses the page to clean it up. Create helpers once, outside the loop, and reuse them.
- **Keep physics small.** Use simple collider shapes, and let objects that do not move be fixed bodies.

```jsx
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'

function Spinner() {
  const mesh = useRef(null)
  // delta is the time since the last frame, so the speed does not depend on the frame rate.
  useFrame((_, delta) => { mesh.current.rotation.y += delta })
  return (
    <mesh ref={mesh} position={[0, 1.3, -1]}>
      <boxGeometry args={[0.2, 0.2, 0.2]} />
      <meshLambertMaterial color="orange" />
    </mesh>
  )
}
```

## Textures and loading

Large textures use memory and take time to upload, which shows up as a hitch when an object first appears. Size textures for how big they are seen, not for how they were exported. GPU-compressed textures in the [KTX 2.0](https://www.khronos.org/ktx/) format stay compressed in graphics memory. Load what the first view needs before the session starts.

## A routine that works

1. Decide the frame rate you will hold, and so your time per frame.
2. Test on the headset early, with the real content, not at the end.
3. When frames drop, run the small-picture experiment to find the side that is over budget.
4. Fix the biggest cost on that side. Measure again.
5. Keep the draw call and triangle counts you ended with as the budget for the scene.

## Doing this in Graspable

Graspable checks that a scene builds, loads without errors, and still passes the headset sessions you recorded. It does not measure frame rate on your headset. An emulated headset on a desktop computer cannot tell you that, and we would rather say so than show a number that means nothing.

What it does make quick is the loop around the measurement. [Open the project on your headset](https://graspable.dev/docs/preview-and-headset.md) from the preview and edits keep hot-reloading there. Ask the agent for a specific change, for example "draw the trees as one instanced mesh" or "print draw calls and triangles every two seconds", and measure again on the device.

## Sources

- [WebXR performance optimization workflow](https://developers.meta.com/horizon/documentation/web/webxr-perf-workflow/), [WebXR app framerate control](https://developers.meta.com/horizon/documentation/web/webxr-frames/) and [WebXR fixed foveated rendering](https://developers.meta.com/horizon/documentation/web/webxr-ffr/), Meta
- [WebXR performance guide](https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API/Performance) and [XRWebGLLayer.fixedFoveation](https://developer.mozilla.org/en-US/docs/Web/API/XRWebGLLayer/fixedFoveation), MDN
- [WebXR Device API](https://www.w3.org/TR/webxr/), W3C
- [Scaling performance](https://r3f.docs.pmnd.rs/advanced/scaling-performance) and [performance pitfalls](https://r3f.docs.pmnd.rs/advanced/pitfalls), React Three Fiber
- [Performance](https://pmndrs.github.io/xr/docs/advanced/performance), @react-three/xr
- [WebGLRenderer.info](https://threejs.org/docs/#api/en/renderers/WebGLRenderer.info) and [InstancedMesh](https://threejs.org/docs/#api/en/objects/InstancedMesh), three.js
