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

How to Optimize GLTF and GLB Models for Three.js

September 9, 2026 · AETumi

Key answer: to optimize GLTF for Three.js, treat the model as a pipeline rather than a single export toggle. Audit where the bytes live, because a GLB is almost always dominated by either geometry or textures and you fix the bigger one first. Prune unused nodes, weld duplicate vertices and dedup shared data with gltf-transform; decimate over-dense meshes or ship level-of-detail variants; compress textures to KTX2 with mipmaps so they stay small in GPU memory; then apply Draco or Meshopt geometry compression. Export one .glb, load it with GLTFLoader wired to the matching KTX2 and Draco loaders, and load heavy models progressively so they never block first paint. This guide walks that GLTF and GLB optimization pipeline end to end � audit, geometry, textures, loader runtime, level of detail and a mobile budget � with runnable commands, real code and a live level-of-detail demo you can toggle.

Table of contents

What GLTF and GLB optimization is

GLTF and GLB optimization is the process of reducing a 3D model's download size, memory footprint and runtime cost so it loads and renders smoothly in a browser, without a visible drop in quality. Three.js reads the glTF format through its GLTFLoader, and glTF is the web's standard delivery format � a JSON scene graph plus binary geometry and image data. A .gltf file keeps those buffers and textures as separate files; a .glb file packs everything into one binary container. When people say GLB optimization, they mean shrinking that container and everything it holds: fewer vertices, smaller textures, compressed buffers and a structure the GPU can upload quickly.

The crucial idea is that to optimize GLTF for Three.js you are optimizing four different things that happen at different moments � transfer size over the network, parse and decode on the CPU, upload to the GPU, and per-frame draw cost � and a single model can be heavy in one and cheap in another. Good 3D model optimization for the web means knowing which of those four is your bottleneck before you touch a single export setting.

Why you should optimize GLTF for Three.js

An unoptimized model punishes the exact users you most want to keep. A raw export straight from a DCC tool routinely carries duplicate vertices, unused animation channels, 4K textures for an object that fills a fifth of the screen, and full-precision buffers where half the precision would be invisible. On a fast desktop connection you might not notice; on a mid-range phone on cellular, the same GLB stalls the main thread while it decodes, spikes GPU memory when its textures upload uncompressed, and delays the moment the page becomes interactive.

The payoff of a proper GLB optimization pass is compounding: a smaller file transfers faster, KTX2 textures occupy a fraction of the GPU memory of their PNG or JPEG sources, and a cleaner mesh means fewer vertices to transform every frame. Because AETumi ships production Three.js and WebGL product experiences, every template in the library treats model optimization as a build step, not an afterthought � the difference between a demo that impresses on a workstation and a store page that converts on a commuter's phone.

The optimization pipeline architecture

The reliable way to optimize GLTF for Three.js is to run the model through an ordered pipeline where each stage removes a specific class of waste. Order matters: you clean structure before you compress, because compressing garbage just produces smaller garbage. The diagram below is the mental model behind every optimized GLB in the AETumi library.

The GLTF to GLB optimization pipelineLeft to right stages: source model, then prune and weld to clean structure, then decimate or add level of detail for geometry, then KTX2 for textures, then Draco or Meshopt for geometry compression, producing an optimized GLB that GLTFLoader reads at runtime. Cleaning happens before compression.source.gltf / DCCprune + weldclean structuredecimate / LODgeometryKTX2texturesDraco / Meshoptcompress buffers.glbGLTFLoaderlossless cleanuptuned lossy compressionclean the structure first, compress the result second
The GLTF to GLB optimization pipeline — lossless cleanup precedes tuned, lossy compression, and GLTFLoader consumes the finished GLB.

Read the pipeline as two halves. The left half is lossless: pruning unused nodes and welding duplicate vertices changes nothing you can see, it only deletes waste. The right half is lossy but tuned per asset: KTX2 and Draco trade a controllable, near-invisible amount of quality for large size savings. Keeping the halves in this order is the single most important discipline in web 3D model optimization, and it maps directly onto the runnable commands later in this guide.

Step 1: audit the model first

Never optimize blind. Before changing anything, inspect the model so you know whether geometry or textures own the byte budget � the two demand completely different fixes. The gltf-transform inspect command prints a full report: mesh vertex counts, every texture's resolution and format, buffer sizes and unused data.

# install once, then inspect any model
npm install -g @gltf-transform/cli

# full report: meshes, textures, materials, animations, byte sizes
gltf-transform inspect model.glb

The report answers the only question that matters at this stage: where are the bytes? If a single 4096×4096 base-color texture dwarfs the whole mesh, geometry compression is a rounding error and you should go straight to textures. If the model is a dense scan with millions of vertices and modest textures, geometry is your target. Why it matters: teams routinely reach for Draco first because it sounds advanced, then wonder why a texture-bound GLB barely shrank. The audit stops you optimizing the wrong half.

AETumi Expert Note

Audit the transferred size, not just the size on disk. Open your browser's network panel, filter to the model request, and read the actual bytes over the wire with your server's compression applied. A GLB that is 6 MB on disk but already served with Brotli tells a different story than the file listing, and it is the transferred number â€â€? not the disk number â€â€? that decides whether the model blocks first paint.

Step 2: clean and decimate geometry

Geometry cleanup is the lossless win almost every model needs. Duplicate vertices from an un-welded export, unused nodes left by a modeler, and redundant accessors all inflate the buffer with data the eye never sees. Clean them first, then decimate only if the vertex count is genuinely too high for its on-screen role.

# lossless structural cleanup � safe on nearly every model
gltf-transform prune   in.glb  s1.glb   # drop unused nodes, materials, textures
gltf-transform weld    s1.glb  s2.glb   # merge duplicate vertices
gltf-transform dedup   s2.glb  s3.glb   # deduplicate shared accessors & textures

# lossy: only when a mesh is denser than its screen size needs
gltf-transform simplify s3.glb clean.glb --ratio 0.5 --error 0.001

prune, weld and dedup are safe to run on essentially any asset � they remove waste without touching appearance. simplify is the one lossy geometry step: --ratio 0.5 targets roughly half the triangles, and --error caps how far the surface may move, so the silhouette holds. Expected behaviour: after the lossless three, the vertex count drops with zero visual change; after simplify, check the object's outline at its real display size, because that is where over-decimation shows first.

When a model appears at wildly different sizes � huge in a hero, tiny in a grid � a single decimated mesh is the wrong answer. That is what level of detail solves, swapping mesh density for on-screen size, and you can feel it in the live demo further down. The same reusable pattern ships as an AETumi 3D component so a viewer picks the right density automatically.

Step 3: compress textures to KTX2

For most product models, textures â€â€? not geometry â€â€? are the real weight, and KTX2 is the highest-leverage fix in the entire pipeline. A PNG or JPEG must be decoded to raw RGBA on the GPU, so a 2048×2048 texture can occupy around 16 MB of video memory regardless of how small the compressed file was. KTX2 with Basis Universal supercompression stays GPU-compressed after upload, cutting both transfer size and memory dramatically.

# convert every texture in the model to KTX2 (Basis Universal)
# UASTC for normal/detail maps (higher quality), ETC1S for color (smaller)
gltf-transform uastc  clean.glb tex1.glb --slots "normalTexture"
gltf-transform etc1s  tex1.glb  tex2.glb --slots "baseColorTexture,emissiveTexture"

# also make sure textures are power-of-two and mipmapped for the GPU
gltf-transform resize tex2.glb  tex3.glb --width 2048 --height 2048

Use UASTC for maps where banding is obvious, such as normal and detail maps, and the smaller ETC1S for base color and emissive. Power-of-two dimensions let the GPU generate mipmaps, which stops distant textures from shimmering and speeds sampling. Trade-off: KTX2 requires the KTX2Loader and a transcoder at runtime, so a page with one tiny texture may not be worth the extra loader � but any texture-heavy model almost always is.

Where the bytes live before and after optimizationTwo stacked bars compare a model’s byte budget. Before optimization, textures dominate the bar, with geometry second and animation small. After optimization, KTX2 shrinks textures the most and geometry compression shrinks geometry, so the whole bar is much shorter. The takeaway is that textures usually own the budget, so fixing them first gives the biggest reduction.BEFOREtexturesgeometryAFTERKTX2Dracotextures — usually the largest, fix first with KTX2geometry — shrink with Draco or Meshoptanimation & other — often already smallfull budgetoptimized
Where the bytes live — for most product models textures dominate the budget, so KTX2 delivers the largest single reduction before geometry compression even starts. Proportions are illustrative; measure your own model with gltf-transform inspect.

Step 4: the gltf-transform pipeline

Running each command by hand is fine while you learn the stages, but production wants one repeatable, scriptable pass. The gltf-transform Node API lets you compose the whole pipeline in the correct order and run it in CI so every model is optimized identically.

import { NodeIO } from '@gltf-transform/core'
import { prune, weld, dedup, simplify, textureCompress } from '@gltf-transform/functions'
import { MeshoptSimplifier } from 'meshoptimizer'
import sharp from 'sharp'

const io = new NodeIO()
const doc = await io.read('in.glb')
await doc.transform(
  prune(),                                            // drop unused data
  weld(),                                             // merge duplicate vertices
  dedup(),                                            // deduplicate accessors
  simplify({ simplifier: MeshoptSimplifier, ratio: 0.6, error: 0.001 }),
  textureCompress({ encoder: sharp, targetFormat: 'ktx2', resize: [2048, 2048] })
)
await io.write('optimized.glb', doc)

This script is how you optimize GLTF for Three.js repeatably: the same ordered pipeline from the architecture diagram, expressed once and versioned with your code. Because it is deterministic, a designer can drop a new model into the repository and CI produces a web-ready GLB with no manual steps. Why it matters: optimization that lives in a script never drifts � the twentieth model gets the exact same treatment as the first, which is how a library of hundreds of assets stays consistently fast.

AETumi Expert Note

Keep the raw source model in version control alongside the optimized output, and never optimize an already-optimized GLB. Re-running simplify or re-encoding KTX2 on a compressed file compounds loss and can visibly degrade the asset. Treat the source as the single source of truth and the pipeline as a pure function from source to shipped GLB � if the result looks wrong, fix the pipeline and re-run from source, not from the output.

Step 5: geometry compression, briefly

Once structure is clean and textures are KTX2, the last size lever is geometry compression � encoding vertex buffers so they transfer smaller. The two mainstream choices in Three.js are Draco and Meshopt, and they trade compression ratio against decode speed and setup differently enough that the choice deserves its own analysis. This guide keeps geometry compression to one line � apply it after cleanup � and points you to the dedicated comparison for the decision.

For the full trade-off between the two, including decoder cost, compression ratio and when each wins, read Draco vs Meshopt for Three.js compression. Here it is enough to know that geometry compression is the final stage of the pipeline, never the first, and that it always ships with a matching runtime decoder � which is exactly what the loader setup below wires up.

Step 6: wire the GLTFLoader runtime

An optimized GLB is only half the job; Three.js needs the decoder loaders that match how you compressed it, or the model simply fails to load. The pattern is to attach a DRACOLoader, a KTX2Loader and the Meshopt decoder to a single GLTFLoader so one loader handles every optimization you applied.

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'

const draco = new DRACOLoader().setDecoderPath('/decoders/draco/')
const ktx2  = new KTX2Loader().setTranscoderPath('/decoders/basis/').detectSupport(renderer)

const loader = new GLTFLoader()
  .setDRACOLoader(draco)
  .setKTX2Loader(ktx2)
  .setMeshoptDecoder(MeshoptDecoder)

loader.load('/models/optimized.glb', (gltf) => scene.add(gltf.scene))

Three details make or break this: the KTX2 transcoder needs detectSupport(renderer) so it picks a GPU format the device actually supports, the decoder files must be served from a real path you host, and the loaders are reusable � create them once and share them across every model. Trade-off: each decoder is an extra asset the browser fetches, so host them yourself and let them cache, rather than pulling from a CDN on every page. Dispose of the loaders when your view unmounts to free their worker pools.

Level of detail, live

Decimation is not a single choice; the right vertex count depends on how large the model appears on screen. Level of detail (LOD) ships several densities of the same mesh and swaps them by distance or role � a full-resolution version for a hero close-up, a lighter one for a thumbnail. The scene below is a live Three.js example: use the buttons to switch quality tiers and watch the triangle count change, exactly the trade-off an LOD makes automatically.

Live · Three.js · wireframe decimation
loading…
Interactive 3D model — enable WebGL and JavaScript to watch triangle density collapse across detail tiers. A static AERAE preview is shown in the media proof below.
Live decimation demo — one icosphere model drawn as a wireframe at three geometry densities. It auto-steps High → Balanced → Low (or use the buttons) and the counter shows the triangle count collapsing from 5,120 down to 80 as the mesh is optimized. Real Three.js, rendered in your browser, not a video.

The demo above is real Three.js, not a video — here is the core of how it builds one model at three densities and reads the triangle count live, the same decimation a Three.js LOD or a gltf-transform simplify --ratio pass encodes:

import * as THREE from 'three'

// IcosahedronGeometry(radius, detail) - triangle count = 20 * (detail+1)^2
const tiers = [ {label:'Low', detail:1}, {label:'Balanced', detail:5}, {label:'High', detail:15} ]
const wire  = new THREE.MeshBasicMaterial({ color: 0x33e1ff, wireframe: true })

const meshes = tiers.map(t => {
  const g = new THREE.IcosahedronGeometry(1.35, t.detail)
  const m = new THREE.Mesh(g, wire)
  m.userData.tris = g.index ? g.index.count / 3 : g.attributes.position.count / 3
  m.visible = false; scene.add(m); return m
})

function show(q){                              // swap density, read the triangle count live
  meshes.forEach((m, i) => m.visible = (i === q))
  stat.textContent = tiers[q].label + ' · ' + meshes[q].userData.tris.toLocaleString() + ' triangles'
}
show(2)                                        // start High (5,120 tris), then decimate down to Low (80)

The counter makes the abstract concrete: the Low tier renders a fraction of the triangles the High tier does, and at thumbnail size the difference is imperceptible while the GPU cost is not. That is the whole argument for decimation and LOD � you are not lowering quality, you are matching quality to how much of the screen the model actually occupies. In a real pipeline you would export these tiers as separate optimized meshes and let a Three.js LOD object switch them by distance.

Step 7: progressive and lazy loading

Even a well-optimized GLB should not block the page. The last stage of GLTF and GLB optimization is when you load � defer the model until it is needed and reveal it once it is ready, so first paint never waits on 3D. Lazy-load with an IntersectionObserver and only construct the loader when the canvas approaches the viewport.

const holder = document.querySelector('#viewer')
const io = new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue
    obs.disconnect()                                  // load once
    loader.load('/models/optimized.glb', (gltf) => {
      scene.add(gltf.scene)
      holder.classList.add('-ready')                  // fade in when decoded
    })
  }
}, { rootMargin: '200px' })                            // start a little early
io.observe(holder)

The rootMargin starts the fetch just before the viewer scrolls into view, so the model is usually ready by the time the user arrives. Pair this with a lightweight poster image behind the canvas and the experience degrades gracefully on slow connections. The loading timeline below shows why deferral matters: it moves the heavy work off the critical path to first paint.

Progressive loading timeline for an optimized GLBA timeline compares two strategies. Eager loading fetches the GLB immediately, so parse, GPU upload and 3D render all sit before first paint and delay it. Lazy loading paints the HTML first, then fetches, decodes and uploads the GLB only as the viewer approaches, so first paint happens early and the 3D appears later without blocking it.first paint (eager)first paint (lazy)EAGERfetch GLBdecodeGPU uploadLAZYHTML + first paintfetch GLBdecodeGPU uploadviewer scrolls neartime →eager 3D work pushes first paint right; lazy loads it off the critical path
Progressive loading timeline — deferring the GLB lets the HTML paint immediately, then fetches, decodes and uploads the model only as the viewer approaches.

The mobile budget

Mobile is where every optimization is judged. The GPU has less memory, the CPU decodes slower, and the network is unreliable � so a model that feels instant on a laptop can stutter on a phone. Set explicit budgets and enforce them in the pipeline: cap texture resolution, cap the renderer pixel ratio, and keep the largest single GLB request small enough that it never dominates the page.

// serve a lighter model and cap the renderer on constrained devices
const small = matchMedia('(max-width: 768px)').matches
const url = small ? '/models/optimized-mobile.glb' : '/models/optimized.glb'
renderer.setPixelRatio(Math.min(devicePixelRatio, small ? 1.5 : 2))
loader.load(url, (gltf) => scene.add(gltf.scene))

Shipping a mobile-specific GLB with smaller textures and a decimated mesh is the single most effective mobile optimization � it attacks transfer, memory and draw cost at once. Capping devicePixelRatio lower on phones matters because fill-rate scales with the square of the ratio, so the drop from 2 to 1.5 is a real saving on a mobile GPU. Expected behaviour: the phone downloads less, uploads smaller textures, and holds frame rate where the desktop model would have thrashed memory.

AETumi Expert Note

Test the model on a real mid-range Android device on a throttled connection, not a flagship on office Wi-Fi. The failure modes that matter � GPU-memory pressure from uncompressed textures, long main-thread decode stalls, and janky first interaction � only appear on constrained hardware. If your optimized GLB holds up there, it holds up everywhere; if you only ever test on a workstation, you are optimizing against the wrong machine.

Real AETumi media proof

Optimization is not academic � it is what lets a detailed product model ship as an interactive web experience instead of a video. The AERAE headphone below is a real AETumi 3D product template: a textured model presented in the browser, the kind of asset the pipeline in this guide exists to make deliverable on a phone.

AERAE � Futuristic 3D Headphone & Audio Tech Website
AERAE � a 3D product experience from the AETumi library. What this proves: when you optimize GLTF for Three.js properly, a texture-rich hero model can be presented crisply on the web and loaded progressively, rather than falling back to a flat video.

Decision matrix

Not every model needs every stage. Match the effort to where the model's cost actually sits � a lightweight object wastes your time in Draco setup, while a texture-bound hero barely notices geometry compression. Use the matrix to decide what to run.

OptimizationWhat it cutsCostUse whenSkip when
Prune / weld / dedupStructural wasteNone (lossless)Always � every modelNever
Simplify / LODVertex countSilhouette riskDense or multi-size meshesAlready low-poly
KTX2 texturesTransfer & GPU memoryExtra loaderAny textured modelUntextured / one tiny map
Draco / MeshoptGeometry transferDecoder + CPU decodeGeometry-heavy modelsTexture-bound models
Lazy / progressiveTime to first paintA little JSModel is below the foldModel is the hero, above fold

When not to over-optimize

Optimization has diminishing returns and real costs, and part of doing it well is knowing when to stop. Do not add Draco to a simple low-poly object whose buffer is already tiny � you pay a decoder and CPU decode for a saving measured in kilobytes. Do not decimate a hero model that is meant to be inspected up close until its silhouette breaks; the whole point of that asset is fidelity. Do not reach for a full GLTF and GLB optimization pipeline when a single static image or a short <video> would communicate the product just as well at a fraction of the engineering. And never re-optimize an already-optimized GLB, which only compounds loss. The pipeline earns its place on interactive, inspectable, conversion-critical 3D � not on every decorative object a design throws into a page.

How AETumi approaches it

AETumi is an AI-native 3D web platform for production Three.js and WebGL sites, React and Next.js components, and MCP workflows for assistants such as Claude Code. Every model in the AETumi library goes through this exact pipeline as a deterministic build step: audit, lossless cleanup, KTX2 textures, geometry compression, and a loader wired for all of it, with mobile-specific GLBs where the budget demands. The same optimized viewer ships as a reusable AETumi 3D component, and the pipeline itself can be driven through an MCP workflow so an assistant optimizes and wires a model against owned source rather than generating an unoptimized scene from scratch. The standard is documented for every template in the AETumi docs.

Technical proof on GitHub

The open repository AETumiApp/threejs-product-viewer is the runnable proof behind this guide. It ships a single-file example at examples/product-viewer.html that loads a model with GLTFLoader and the supporting decoder loaders, and GUIDE.md documents the optimization and loader setup step by step; examples/README.md explains how to run it locally. For the reusable pattern, AETumiApp/aetumi-3d-components carries a matching example at examples/product-showcase.html plus its own GUIDE.md. Read the repositories as the working code and this article as the architecture and decision context � they are meant to be used together rather than duplicating each other.

FAQ

How do I optimize a GLTF model for Three.js? Treat it as an ordered pipeline. Audit the model to see whether geometry or textures own the byte budget, prune and weld to remove structural waste, decimate or add LODs for over-dense meshes, compress textures to KTX2, then apply Draco or Meshopt geometry compression. Export one .glb and load it with GLTFLoader plus the matching decoder loaders. Always fix the largest cost first instead of guessing.

What is the difference between GLTF and GLB for optimization? They are the same format packaged differently � .gltf is JSON referencing external files, .glb packs JSON, buffers and textures into one binary. GLB optimization is preferred for the web because a single file means one request, no missing assets and simpler caching. Both accept the same Draco, Meshopt and KTX2 optimizations.

Does optimizing GLB reduce quality? In the right order it is nearly lossless to the eye. Welding, pruning and dedup change nothing visible; KTX2 and Draco are lossy but tuned per asset so the loss is invisible at normal distance. Only aggressive decimation visibly alters a model, which is why you decimate last and check the silhouette.

How large should a web GLB be? Budget by role � a hero model that must look sharp can justify a few megabytes if it loads progressively, while a repeated or background model should be a few hundred kilobytes. The real target is keeping your largest 3D request from blocking first paint on a mid-range phone. Measure the transferred size in the network panel, not the size on disk.

Do I need Draco and KTX2 on every model? No. KTX2 helps almost any textured model because it stays compressed in GPU memory. Draco or Meshopt help dense meshes but add a decoder and CPU decode, so they are overkill for a simple low-poly object. Profile first: if textures dominate, fix textures before reaching for geometry compression.

Continue with Three.js, WebGL, reusable 3D components, React Three Fiber, interactive websites, the MCP workflow, the AETumi docs, options for agencies, and the deep dive on Draco vs Meshopt compression.

Conclusion

To optimize GLTF for Three.js is to run one ordered pipeline: audit where the bytes live, clean structure losslessly, decimate or add LODs, compress textures to KTX2, compress geometry with Draco or Meshopt, wire a GLTFLoader that decodes all of it, and load progressively so nothing blocks first paint. Fix the biggest cost first, keep the pipeline deterministic in CI, and test on real mobile hardware. Do that and a detailed model becomes a fast, interactive web asset instead of a payload that punishes the phone it lands on � which is exactly the standard behind every 3D template AETumi ships.

More from the AETumi library

Real, production-ready 3D � preview the motion, grab the source.

Browse all 3D website templates →