Good 3D website performance comes down to three moves: set a hard budget for triangles, file size and draw calls before anyone models anything; compress every asset (Draco or meshopt for geometry, KTX2 for textures); and load the 3D only after the HTML page has painted, with a poster image or video fallback for weaker phones. Do those and real-time 3D can sit comfortably inside Google's Core Web Vitals.
Here's how we do each step at the Lab.
Set a 3D performance budget first
Performance is decided in the brief, not the last week. These are the working targets we hold a mobile hero scene to: our own targets, not an industry standard.
| Budget item | ASFARI Lab working target |
|---|---|
| Triangles, mobile hero scene | ≤ 100k |
| Hero model, compressed GLB | ≤ 2 MB |
| Textures | 1K–2K, KTX2 |
| Draw calls | ≤ 50 |
| First interaction, mid-range phone | Under 3 s |
| 3D bundle | Loaded after first paint |

A draw call is one instruction from the browser to the graphics chip ("draw this mesh with this material"). Fewer, bigger calls beat many small ones, which is why merging meshes and using instancing matters more than shaving a few thousand triangles.
glTF compression: Draco, meshopt and KTX2
glTF is the standard 3D format for the web (Khronos). Raw exports are big; compressed ones often aren't.
- Draco compresses mesh geometry. Great on dense meshes; costs a little decode time.
- meshopt is an alternative geometry compression that decodes very fast. Worth testing against Draco on your model.
- KTX2 / Basis Universal compresses textures into a format the graphics chip can use directly. A JPG or PNG is small to download but gets unpacked to full size in GPU memory; KTX2 stays compressed there, which is what saves phones from crashing.
With the open-source glTF-Transform CLI, a first pass looks like this (commands from the docs):
npm install --global @gltf-transform/cli
gltf-transform inspect hero.glb # triangles, textures, sizes
gltf-transform optimize hero.glb hero-opt.glb --compress draco --texture-compress webp
For KTX2, use the CLI's KTX2 texture commands instead of WebP (they need KTX-Software installed). Then tell Three.js how to decode both:
const draco = new DRACOLoader().setDecoderPath('/draco/');
const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(renderer);
const loader = new GLTFLoader().setDRACOLoader(draco).setKTX2Loader(ktx2);
Run inspect before and after. Still over budget? The fix is usually in Blender, not more compression.
Load the 3D after first paint
A 3D library plus models is a lot of code. Utsubo reports the Three.js core at about 600 KB minified and over 3 MB with helpers, post-processing and a GLB. If that blocks the page, your Largest Contentful Paint suffers.

The pattern:
- Server-render the HTML, including headline and CTA.
- Show a poster image in a slot with a fixed aspect ratio, so nothing shifts when the canvas arrives.
- Import the 3D code once the page is idle or the slot scrolls into view.
- Crossfade from poster to canvas when the first frame is ready.
const start = () => import('./hero-scene.js').then(m => m.mount(canvas));
'requestIdleCallback' in window ? requestIdleCallback(start) : setTimeout(start, 200);
GitHub does the same on its homepage globe: an SVG placeholder shows until the first frame renders, then crossfades to the canvas over 600 ms. This also matters for search, which we cover in WebGL SEO.
WebGL performance on mobile: device tiers
Not every phone gets the same scene. Plan at least three tiers:
- High: full scene, pixel ratio capped at 2 (
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))). - Low: fewer particles, no post-processing, pixel ratio 1–1.5.
- Fallback: a pre-rendered video loop or still image. Muted video with
playsinlinecan autoplay inline on iOS.
Adapt at runtime, too. GitHub's globe watches frame rate and degrades if it can't hold 55.5 FPS over the last 50 frames, stepping through four quality tiers: lower pixel ratio, fewer raycasts and about a third fewer dots.
To optimize Three.js performance during development, log renderer.info.render.calls and renderer.info.render.triangles every frame and compare them to the budget. And respect prefers-reduced-motion: those visitors get the poster, not the spinning scene.
Measure against Core Web Vitals
Google's "good" thresholds are LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1. On a 3D page, the canvas or poster is often the LCP element, and long shader compiles show up as poor INP.

Utsubo estimates 50–300 ms per shader compile and 50–200 ms per large texture decode, so warm up shaders before the user interacts. For the broader animation picture, read do animations slow down websites?. For single product models rather than full scenes, see how to put an interactive 3D model on your website.



