A performance budget for a site with WebGL
A WebGL scene is the most expensive thing on most pages that have one. It competes with layout, input handling and everything else for the same device, and unlike a large image, it keeps costing on every frame. A budget turns "make it light" into numbers you can check, break and argue about. Set it early, from the weakest device you intend to support, not from your own machine.
What a budget is
web.dev defines a performance budget as a set of limits on metrics that affect site performance: total size, load time, number of requests. For a site with a continuous scene add limits on the things that cost per frame. A budget is useful only if it can fail a build or a review, so write each line as a measurable cap with a way to check it.
An example for a fictional landing page with one particle scene (set your own from your lowest supported device):
| Dimension | Cap | How to check |
|---|---|---|
| Frame time, steady state | 10 ms of our work at 60 Hz | DevTools Performance, frames track |
| Device pixel ratio | 1.75 maximum, 1.0 on the lowest tier | Code review; renderer.getPixelRatio() |
| Particle count | 12,000 / 7,000 / 4,000 by tier | Constants in one file |
| Draw calls per frame | 20 or fewer | renderer.info.render.calls |
| Texture memory | 32 MB total | Arithmetic from sizes, below; renderer.info.memory.textures only to spot leaks |
| Post-processing passes | 0 on low tier, 2 on high | Count in code |
| Script that blocks the main thread | No task over 50 ms | Performance panel, long tasks |
| WebGL code and library | Loaded after first paint, by dynamic import | Network panel |
| Initial HTML | Contains all text and the LCP element | curl |
None of these numbers is a standard. They are a way of making a decision visible and checkable.
Frame time
At 60 Hz a frame has 1000 / 60 ≈ 16.7 ms; at 120 Hz, 1000 / 120 ≈ 8.3 ms. That interval is not all yours: the browser also runs style, layout, paint, compositing and your event handlers within it. A working rule is to keep your own per-frame JavaScript and submission cost near 10 ms at 60 Hz so the rest fits, and to remember that GPU time is separate from CPU time. A scene can use 2 ms of JavaScript and still run at 20 frames per second because the GPU cannot fill the pixels in time. Measure both.
Consistency matters more than peak speed. A steady 45 frames per second looks smoother than an average of 55 with stutters. Stutters usually come from garbage collection (allocating objects in the frame loop), shader compilation on first use, and texture uploads in the middle of an animation. MDN also warns that calls such as getError and readPixels can stall the thread until the GPU catches up, so keep them out of the frame loop.
The four caps that matter
1. Pixels (device pixel ratio)
The number of pixels the GPU must shade is width × height × ratio². At 1440 by 900 CSS pixels:
| Pixel ratio | Pixels shaded | Relative |
|---|---|---|
| 1.0 | 1,296,000 | 1.00 |
| 1.5 | 2,916,000 | 2.25 |
| 1.75 | 3,969,000 | 3.06 |
| 2.0 | 5,184,000 | 4.00 |
Going from 2.0 to 1.5 removes 44 percent of the work. Glows, particles and nebula backgrounds are soft, so the loss is hard to see. Cap with renderer.setPixelRatio(Math.min(devicePixelRatio, 1.75)), and render soft full-screen backgrounds at half resolution and let the browser scale them up. The three.js docs say setPixelRatio also resizes the canvas if necessary, so call it on change, not every frame.
2. Geometry and particles
Vertex data is rarely the problem. Twelve thousand particles with three 32-bit floats each is 144,000 bytes of positions, about 141 KiB. The cost is overdraw: how many sprite pixels land on the same screen pixel. As an arithmetic example, 12,000 round sprites of 8 device pixels in diameter cover about 50 pixels each, so roughly 603,000 pixel writes per frame before any overlap, around 47 percent of the 1,296,000 pixels in the ratio 1.0 row above. Larger sprites stacked with additive blending pass the screen several times over. Details are in particle systems in the browser.
A Points object with one material and no geometry groups is normally one draw call, so particle count mostly affects vertex and fill work; confirm with renderer.info.render.calls rather than assuming. For other geometry, keep draw calls low by merging static meshes and by instancing repeated ones. MDN's WebGL best practices make the same point: batch draw calls, and use texture atlases so a change of texture does not split a batch.
3. Textures
Memory for an uncompressed 8-bit RGBA texture is width × height × 4 bytes, plus about a third more for mipmaps (MDN puts the mipmap overhead at around 30 percent):
| Size | Base | With mipmaps |
|---|---|---|
| 1024 × 1024 | 4 MiB | about 5.3 MiB |
| 2048 × 2048 | 16 MiB | about 21.3 MiB |
| 4096 × 4096 | 64 MiB | about 85.3 MiB |
One 4096 texture is more than the whole 32 MB example cap, and a single 2048 texture with mipmaps takes about two thirds of it. Use the smallest size that looks right at the largest on-screen size, remember that the pixel ratio cap also reduces what you need, and consider GPU-compressed formats for large atlases. MDN notes they are smaller in GPU memory and faster to sample, at some cost in quality, and that no single format works on every system. three.js's KTX2Loader reads KTX 2.0 containers and transcodes to a compressed format the device supports.
The renderer's info.memory object reports only counts of active geometries and textures, not bytes, so use it to catch leaks: after you tear a scene down, the counts should return to what they were before it loaded.
4. Shader cost
Cost per pixel is multiplied by pixels shaded, which is why shaders belong in the budget. Watch for: many texture reads per pixel, loops with large or variable counts, noise functions called several times per pixel (compute at lower resolution instead), branches that diverge, and full-screen passes. MDN's guidance is to do as much work as you can in the vertex shader rather than the fragment shader, since fragment shaders run many more times per draw call. Each post-processing pass renders the whole screen again and reads the previous result, so three passes triple fill cost before any scene work. Replace a blur pass with a pre-blurred sprite where you can.
Tier by capability, then verify by measurement
A fixed tier chosen from a guess is brittle. Combine a cheap first guess with a runtime check.
First guess, from signals that exist in browsers: whether the primary pointer is coarse (matchMedia('(pointer: coarse)')), navigator.hardwareConcurrency (logical processors), navigator.deviceMemory (MDN marks it limited availability, secure contexts only, and coarsened to a power of two), and whether context creation succeeded at all. The renderer string from the WEBGL_debug_renderer_info extension can reveal software rendering, but browsers may mask or restrict it, so treat it as a hint.
Runtime check. Time real frames, skip the first half second where shader compilation distorts them, and step down if the average is poor. Step only downward, to avoid oscillating:
const tiers = [
{ ratio: 1.75, count: 12000 },
{ ratio: 1.25, count: 7000 },
{ ratio: 1.0, count: 4000 },
];
const SKIP = 30, SAMPLE = 60; // frames to ignore, then frames to average
let tier = 0, seen = 0, sum = 0, previous = 0;
function applyTier(index) {
renderer.setPixelRatio(Math.min(devicePixelRatio, tiers[index].ratio));
renderer.setSize(innerWidth, innerHeight, false);
geometry.setDrawRange(0, tiers[index].count); // draw fewer particles, no rebuild
}
function restartSampling() { seen = 0; sum = 0; previous = 0; }
document.addEventListener("visibilitychange", restartSampling);
renderer.setAnimationLoop((now) => {
if (previous) {
seen++;
if (seen > SKIP) sum += now - previous;
if (seen === SKIP + SAMPLE) {
if (sum / SAMPLE > 20 && tier < tiers.length - 1) applyTier(++tier);
restartSampling();
}
}
previous = now;
renderer.render(scene, camera);
});
The visibilitychange line is there for a reason. MDN says requestAnimationFrame callbacks are paused in most browsers in background tabs, so the first frame after a visitor returns carries a gap of seconds. I ran the sampling logic against simulated timestamps. At a steady 16.7 ms it never stepped down, at 30 ms it stepped down twice, and with one 5,000 ms gap inside a sample and no reset it stepped down once on a perfectly healthy page, because 5,000 ms spread over 60 frames adds 83 ms to the average. Resetting the sample when visibility changes removed the false step.
Keep the lowest tier genuinely lighter, and give devices with no usable WebGL the static designed version. Content stays identical across tiers; only fidelity changes. That static version is also the one used under reduced motion, as described in prefers-reduced-motion, done properly.
Render only when something changes
A page that is still does not need 60 frames a second of identical output. The three.js manual describes rendering on demand: render once at the start, then only when something changes, such as a texture finishing loading, a setting changing or the camera moving. It says continuous rendering of a still scene wastes power and, on a portable device, battery.
- Hidden tabs: stop the loop on
visibilitychangewhendocument.hiddenis true. - Off-screen canvas: use an
IntersectionObserverto pause when the canvas is not visible. - Idle scenes: when no input has arrived for a while and the scene has settled, stop requesting frames and restart on pointer or scroll.
- Static output: if the scene is a still, render one frame and stop.
This is the largest saving on battery and fans, and the cheapest to implement.
Release what you create
A scene that is torn down, or replaced on a route change, should free its GPU resources. The three.js manual on freeing resources says geometries, materials and textures must be released with their dispose() methods, and that disposing a material does not dispose its textures, because a texture can be shared by several materials. The cleanup is yours to track. A leak shows up as a steady rise in renderer.info.memory counts each time the scene is built, so log them before and after a teardown.
Keep the page's vitals clean
A scene should not damage the metrics covered in Core Web Vitals in plain language:
- LCP. web.dev's list of LCP candidates (images, SVG images, video posters, elements with CSS background images, and block-level text) does not include canvas, and its heuristics also exclude elements that cover the full viewport as likely background. A full-screen canvas is therefore not your LCP element. Make the headline or hero image in the HTML your LCP element, delivered by static HTML, and start the scene after it paints, using a dynamic
import()triggered when the browser is idle or on the first frame after load. - CLS. Give the canvas its size in CSS (
position: fixed; inset: 0or anaspect-ratio), not only through width and height attributes set by script. - INP. Heavy work on the main thread delays input. web.dev defines a long task as any task over 50 ms. Keep each frame's script work short, do large buffer generation (sampling points, building geometry) in chunks or a worker, and avoid allocating arrays inside the loop.
Measure
- Performance panel. Record a few seconds while scrolling. The frames track marks dropped frames in red, the main thread shows tasks, and a GPU section shows long submissions.
- FPS meter. The Frame rendering stats overlay in DevTools gives a real-time estimate of frames per second while you interact.
- Renderer counters. Log
renderer.info.render.calls,.trianglesand.points, andrenderer.info.memory.texturesand.geometries. By default these reset on everyrender()call, so with post-processing setrenderer.info.autoReset = falseand callrenderer.info.reset()once per frame, as the three.js docs describe, or you will read only the last pass. Fail a review if the totals exceed the budget. - Throttle. Use CPU throttling in DevTools, but know what it is: Chrome's documentation says a 2x slowdown makes the CPU two times slower than usual, and that DevTools cannot truly simulate mobile CPUs. For GPU limits, test on a real mid-range phone and an older laptop with integrated graphics.
- Lighthouse. Lighthouse applies simulated throttling by default, which predicts a slower load from an unthrottled run. Use it for the page's baseline vitals, and measure the live scene separately on real hardware.
- Repeat after each change to scene, assets or libraries.
Checklist
- Write the budget before building: frame time, pixel ratio, particle count, draw calls, texture memory, passes, long tasks.
- Cap the pixel ratio and render soft backgrounds at reduced resolution.
- Tier by signals, confirm by measuring real frames, only step down, and reset the sample when the tab is hidden.
- Pause when hidden, off screen or idle; draw one still under reduced motion.
- Dispose geometries, materials and textures you create, and watch the renderer counts.
- Keep a real text or image LCP element in the HTML and load the scene after first paint.
- Test on the weakest device you promise to support, not the one on your desk.
Questions and answers
How fast should a WebGL scene run?
Aim to hold the display's refresh rate, commonly 60 frames per second, which leaves 16.7 ms per frame for everything the browser does. A 120 Hz display leaves 8.3 ms. Treat a steady lower rate as better than an uneven higher one, and step quality down on devices that cannot keep up.
Does a WebGL canvas count as the Largest Contentful Paint element?
According to web.dev's definition, the elements considered are images, SVG images, video posters, elements with CSS background images and block elements containing text. A canvas is not in that list, so make sure real text or an image in the HTML is your LCP element.
Should I cap the device pixel ratio?
Yes. Pixels rendered grow with the square of the ratio, so a cap of 1.5 to 2 removes a large share of fill cost on dense screens, usually with little visible loss for soft, glowing content. Check your own scene by eye.
How do I handle devices with no GPU acceleration?
Detect failed context creation, and treat a software-renderer hint as a hint only, then serve the static, designed version of the scene. Do not ship a blank canvas. The content of the page must be the same in every tier.
Sources
- web.dev: Performance budgets 101 web.dev
- three.js manual: Rendering on demand threejs.org
- three.js docs: WebGLRenderer threejs.org
- MDN: WebGL best practices developer.mozilla.org
- Chrome DevTools: Performance features reference developer.chrome.com