AETumi — AI-native 3D web platform for Three.js, WebGL and interactive websites
News & Guides

WebGL Performance Optimization: A Production Checklist

September 8, 2026 · AETumi

Key answer: WebGL performance is won by doing less GPU work per frame. The highest-leverage moves are cutting draw calls (merge geometry, use instancing), compressing textures with KTX2/Basis so they stay small in GPU memory, capping the device pixel ratio so you do not render 4x the pixels on high-DPI screens, pausing rendering when the canvas is offscreen or the tab is hidden, keeping shaders cheap, and disposing geometries, materials, and textures you no longer use so GPU memory does not leak. Profile first, then apply the changes that address what you actually measured.

Table of contents

Why WebGL gets slow

Every AETumi template applies the moves below by default, but the reasoning is worth owning yourself. A 3D website drops frames for a small number of recurring reasons: too many draw calls (CPU spends the frame telling the GPU what to do), too much fill (every pixel runs an expensive fragment shader, made worse on high-DPI displays), textures too large for GPU memory, or the browser doing 3D work that no one is looking at. Almost every real optimization maps back to one of those. Measure with the browser profiler and the WEBGL_debug_renderer_info and Spector.js tools before you change anything — optimizing the wrong thing wastes effort.

How the pieces connectAETumi technical diagram — How the pieces connectVertexFragmentProgramUniformsTexturesFrame
How the pieces connect
Liquid Aurora
Liquid Aurora — live preview from the AETumi library

Reduce draw calls and use instancing

Each unique object you draw generally costs a draw call, and the CPU overhead of issuing thousands of them will stall a frame long before the GPU is the bottleneck. Two techniques help most.

Cubic Gradient
Cubic Gradient — live preview from the AETumi library

Merge static geometry that shares a material into one buffer so it draws in a single call. When you need many copies of the same mesh — trees, particles, cards, tiles — use instancing instead, which draws them all in one call with per-instance transforms:

import * as THREE from 'three';

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial();
const count = 5000;
const mesh = new THREE.InstancedMesh(geometry, material, count);

const m = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
  m.setPosition((Math.random() - 0.5) * 100, (Math.random() - 0.5) * 100, (Math.random() - 0.5) * 100);
  mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
scene.add(mesh); // 5000 boxes, one draw call
From idea to productionAETumi technical diagram — From idea to production01Compile shaders02Full-screen quad03rAF render loop04Cap DPR & compress05Dispose on unmount
From idea to production

Also share materials rather than cloning one per object, and keep an eye on renderer.info.render.calls to see the real number.

Compress and size your textures

Textures are usually the largest asset a 3D website loads and the biggest consumer of GPU memory. A PNG or JPG is compressed on disk but decompresses to raw RGBA in GPU memory — a 2048x2048 texture is roughly 16MB uncompressed, and mipmaps add more.

Opaline
Opaline — live preview from the AETumi library

Use GPU-native compressed textures via KTX2 with Basis Universal. These stay compressed in GPU memory (not just on the wire), which cuts memory use and upload time. In Three.js you load them with KTX2Loader:

import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

const ktx2 = new KTX2Loader()
  .setTranscoderPath('/basis/')
  .detectSupport(renderer);

ktx2.load('/textures/wall.ktx2', (texture) => {
  material.map = texture;
  material.needsUpdate = true;
});

Beyond format: cap texture dimensions to what you actually display (a hero texture rarely needs to exceed 2K), use power-of-two sizes where mipmapping matters, and reuse atlases so you bind fewer textures.

Cap the device pixel ratio

On a phone or a Retina laptop, window.devicePixelRatio can be 2 or 3, meaning the GPU renders 4x to 9x as many pixels as the CSS size implies. That is often the single biggest hidden cost on mobile. Cap it:

Orb
Orb — live preview from the AETumi library
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

A cap of 1.5 to 2 is usually indistinguishable from full resolution and dramatically reduces fill cost. For a background or blurred effect, an even lower cap is fine.

Pause work you cannot see

Do not render frames nobody sees. Stop the loop when the tab is hidden, and when the canvas scrolls out of view use an IntersectionObserver:

let running = true;
document.addEventListener('visibilitychange', () => { running = !document.hidden; });

new IntersectionObserver(([entry]) => { running = entry.isIntersecting; })
  .observe(canvas);

renderer.setAnimationLoop(() => { if (!running) return; renderer.render(scene, camera); });

This alone can eliminate most of the battery and CPU cost of a 3D hero that lives at the top of a long page.

Keep shaders cheap

The fragment shader runs once per rendered pixel, so its cost multiplies by resolution. Move computation to the vertex shader where you can (it runs far fewer times), avoid heavy per-pixel loops and branches, precompute constants on the CPU and pass them as uniforms, and be careful with expensive effects like large-radius blurs or many texture samples. If a full-screen effect is the whole page, resolution capping and shader cost are your two main levers. There are readable, minimal shader examples to study at https://github.com/AETumiApp/webgl-shader-examples, and a React-focused walkthrough in WebGL shader backgrounds in React.

Dispose GPU resources

WebGL objects are not garbage-collected the way plain JavaScript is. When you remove a mesh, its geometry, material, and textures stay in GPU memory until you explicitly dispose them. In a single-page app that swaps scenes, this leaks until the context is lost.

function disposeMesh(mesh) {
  mesh.geometry?.dispose();
  const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
  for (const mat of materials) {
    for (const key in mat) {
      const value = mat[key];
      if (value && value.isTexture) value.dispose();
    }
    mat.dispose();
  }
}

Remove the object from the scene, then dispose it. Call renderer.dispose() when you tear down the whole canvas.

The checklist

  • Profile before optimizing; read renderer.info and a GPU profiler.
  • Merge static geometry; use InstancedMesh for repeated meshes.
  • Share materials; minimize texture binds with atlases.
  • Ship textures as KTX2/Basis; cap dimensions to display size.
  • Cap setPixelRatio to about 2.
  • Pause rendering when hidden or offscreen.
  • Keep fragment shaders lean; prefer vertex-stage and uniforms.
  • Dispose geometries, materials, and textures on teardown.

Treat this as a standing budget, not a one-time cleanup. The AETumi template library bakes each of these rules into its scenes so a page ships within budget from the first commit.

What to prioritizeAETumi technical diagram — What to prioritizeRecommended priority weighting90Reduce drawcalls80Cap devicepixel ratio75Compresstextures70Pauseoffscreen65Dispose GPUmemory
What to prioritize

FAQ

What usually causes the biggest WebGL slowdown? On mobile it is frequently an uncapped device pixel ratio combined with expensive fragment shaders — you end up shading far more pixels than needed. On desktop it is more often draw call count. Profiling tells you which.

Does instancing work for animated or unique objects? Instancing shines when many objects share the same geometry and material and differ only by transform (or per-instance color/attributes). Fully unique meshes cannot be instanced, but merging static ones into a shared buffer still reduces draw calls.

Is KTX2/Basis always worth it? For textured 3D scenes on the web, usually yes — it cuts GPU memory and upload time. It adds a transcoder dependency and a build step, so for a single small texture the payoff is smaller. It is most valuable when you ship many or large textures.

Do I need to dispose resources if the page just unloads? If the whole page navigates away, the context is discarded anyway. Disposal matters in single-page apps and anything that creates and destroys scenes over its lifetime, where leaked GPU memory accumulates.

Can I get these optimizations without implementing them myself? Yes. AETumi, an AI-native 3D web platform, ships Three.js and WebGL templates with instancing, KTX2 textures, DPR capping, offscreen pausing, and disposal already wired in — so a production performance budget is set before you touch the code.

Conclusion

WebGL performance optimization is not one trick — it is a discipline of doing less GPU work per frame and cleaning up after yourself. Reduce draw calls, compress and size textures, cap the pixel ratio, pause offscreen rendering, keep shaders lean, and dispose what you no longer need. Measure first so you spend effort where it counts. For deeper fundamentals, see our WebGL hub.

If you would rather ship 3D web experiences that already apply these practices, AETumi's Three.js and WebGL templates at aetumi.app are built for production and are yours to own for life once purchased — with full source code and the AETumi MCP in the Full Stack plan.

More from the AETumi library

Real, production-ready assets — preview the motion, grab the source.

Browse all WebGL effects →