1. Home
  2. Blog
  3. 3D web / WebGL

3D Website Performance: glTF, Draco, KTX2, Mobile Fallbacks

The budget, compression steps and loading order we use to keep real-time 3D fast on a mid-range phone.

ASFARI Lab5 min readOctober 2026

A dense chrome sphere beside a lighter faceted version of itself, showing a 3D model before and after optimization

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
Chart of ASFARI Lab working targets for a mobile 3D scene: triangles, GLB size, texture size, draw calls and time to interaction
Our working budget for a mobile hero scene. Desktop can go higher; mobile sets the ceiling.

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.

Diagram of a 3D loading strategy: HTML and poster first, 3D bundle after first paint, then interactive scene crossfades in
Load order: HTML and poster first, 3D code second, interactivity last.

The pattern:

  1. Server-render the HTML, including headline and CTA.
  2. Show a poster image in a slot with a fixed aspect ratio, so nothing shifts when the canvas arrives.
  3. Import the 3D code once the page is idle or the slot scrolls into view.
  4. 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 playsinline can 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.

Core Web Vitals thresholds for LCP, INP and CLS, showing good, needs improvement and poor ranges
Google's Core Web Vitals thresholds. Test on a real mid-range phone, not just a desktop Lighthouse run.

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.

Questions people ask

Sources

LET’S OPEN A NEW TAB

What are you
thinking?

What’s on your mind?

This prepares an email to lab@asfari.studio. You review and send it from your email app.