August 2, 2026 · 2dto3D
Optimizing 3D models for the web: what actually works
A 3D model that looks perfect in your modelling tool can be unusable on a product page. The failure is specific and worth recognising: the progress bar reaches 100%, then nothing happens for several seconds. The file finished downloading and the browser is still decoding it.
We hit this with our own output. A model leaving the generation pipeline is around 60MB, which is fine as a source file and hopeless as something to put on a page. This is what we learned getting it down, including the parts that surprised us.
Look at your textures before you touch your geometry
The instinct is to reduce triangles. Usually that is the wrong first move, because textures dominate the file. A single 4K albedo map can outweigh the entire mesh, and a model with separate albedo, normal, roughness and occlusion maps at 4K carries four of those.
Check the split before optimizing anything. If textures are 80% of your file, halving your triangle count buys you very little and costs you visible surface detail. Resizing textures to 2048 is often invisible at the size a product renders on a page and removes most of the weight.
Compress geometry with meshopt
For the geometry that remains, the meaningful choice is between Draco and
EXT_meshopt_compression. Both shrink files substantially. We use meshopt, and the reason is
decode speed rather than compression ratio: meshopt is designed to unpack fast, which matters
because decode time is the stall people actually notice. Draco often produces a slightly smaller
file that takes longer to become a visible model.
Running a model through gltf-transform with meshopt and quantization brings ours to roughly a
tenth of the source size. That is the single biggest win available, and it is close to free in
visual terms.
The decoder gotcha nobody warns you about
Here is the one that cost us real time. model-viewer ships no meshopt decoder. You compress
a model, load it, and get a blank viewer with no obvious error.
You have to tell it where to find a decoder, and you should serve that decoder yourself rather than pointing at a CDN you do not control:
const viewer = document.querySelector("model-viewer");
viewer.meshoptDecoderLocation = "/meshopt_decoder.mjs";
Wire it once, centrally. Every viewer surface you add later inherits it, and a new surface that forgets renders nothing at all.
One optimized model is not enough, because AR does not want it
This is the part we got wrong initially, and it is the most useful thing in this post.
We assumed the compressed web model would serve every surface. It does not, because AR handoff on both mobile platforms rejects it, for two unrelated reasons:
- Android Scene Viewer cannot parse meshopt. The compression that makes your web model fast is the thing that makes it unopenable.
- iOS Quick Look crashes building a USDZ from a mesh in the millions of triangles. Not slow, crashes.
So AR needs its own asset, and it wants the opposite of what the web wants: uncompressed, and lighter in geometry. Ours is decimated to around 500,000 triangles with textures at 4096, and meshopt and quantization deliberately stripped back out. It is larger on disk than the web model and it is the one that works.
For iOS specifically we pre-generate a USDZ rather than letting the device build one. USD is a
text-based format, so geometry dominates its size far more aggressively than in a binary GLB, and
that file is decimated harder still, to roughly 150,000 triangles at 2048. Serving it as ios-src
also means AR launches from Chrome and Firefox on iOS, not only Safari.
The general rule: the file you optimize for a browser and the file you hand to AR are different files. Any pipeline that produces only one will fail somewhere.
Keep the source untouched
Every optimized variant is derived. The original stays exactly as generated and is never overwritten.
This sounds obvious and is easy to violate, usually by writing an optimization back over the source to save storage. We did a version of this once with a colour-correction step and quietly degraded finished models with no way to recover them, because the input was gone. Storage is cheaper than regeneration, and far cheaper than an unrecoverable regression.
Expect this to need real memory
Worth knowing before you architect around it: this pipeline is memory-hungry. Ours does not fit in a 128MB serverless worker. Loading a 60MB GLB, decompressing it, decimating, resizing textures and re-encoding needs multiples of the file size in headroom, so the work runs in a container and the worker just coordinates.
If you are planning to do this at the edge, test with your largest realistic file early. The failure mode is an out-of-memory kill on exactly the models that most needed optimizing.
A practical order of operations
- Measure the texture-to-geometry split before deciding anything.
- Resize textures to what the render size justifies. Usually 2048.
- Compress geometry with meshopt, and quantize.
- Point your viewer at a decoder you host yourself.
- Build a separate AR asset: plain, no meshopt, decimated.
- Pre-generate a USDZ for iOS, decimated harder than the AR GLB.
- Never overwrite the source.
Steps one through four are most of the file size. Step five is most of the bugs.