obOB STUDIO
← Back to blog
Web Tech

GLB Not Loading in three.js? Draco's Decoder Is Probably Why

6 min read

If a .glb returns HTTP 200 but throws Cannot read properties of undefined (reading 'extensions'), look at the decoder before you look at the model. drei's useGLTF ships the meshopt decoder inside your bundle, but the Draco path fetches two more files from a Google CDN at runtime. Recompressing with meshopt removed the whole class of failure for me. At the cost of some geometry bytes, which I'll show you honestly further down.

the models that never showed up

The 3D showcase on my homepage loads a real model for each project (a plane, a car, a planet) wrapped in a <Suspense> whose fallback is a plain primitive shape. So while a model is loading you see a stand-in, and once it arrives the real thing swaps in.

Except the real thing never arrived. Every model stuck on its stand-in: a set of shiny coloured blobs, capsules and cones and octahedrons in each project's accent colour, sitting there looking deliberate. The page didn't look broken. It looked like a design choice. Which is so much worse, because nothing prompts you to investigate. And one line in the console:

Could not load /models/toycar.glb: Cannot read properties of undefined (reading 'extensions')

(That's the full string, worth pasting exactly. It's what you'll be searching for.)

The files were there. Correct paths, HTTP 200, right byte counts, sitting on the server looking perfectly healthy. They simply refused to become models. Which is a strange kind of bug, because everything you'd normally check comes back fine and you start to wonder if the problem is you.

why Draco-compressed GLB files fail to load

Every optimisation guide points you at Draco. It compresses glTF geometry harder than the alternatives, it's from Google, it's everywhere in the tooling, and every online GLB compressor offers it as the default. So my models were Draco. KHR_draco_mesh_compression, all three.

Here's the bit nobody mentions, and I do mean nobody. I went looking afterwards. Choosing a compression format isn't only a decision about size. It's quietly also a decision about where the decoder comes from, and that second half never makes it into the comparison tables.

I load models with drei's useGLTF. It defaults to meshopt enabled, and the meshopt decoder is bundled. It ships inside the dependency tree, it's already sitting in your JavaScript, and it works with no network access at all.

The Draco path doesn't work like that. The decoder is a separate WebAssembly module fetched at runtime, and it isn't even one file. DRACOLoader's _initDecoder pulls both draco_wasm_wrapper.js and draco_decoder.wasm. So a Draco-compressed model isn't one request, it's three, and two of them go to a Google host you've never thought about, on the critical path of your page rendering.

When something in that chain doesn't land, you don't get a clear "couldn't get the decoder." You get an error pointing at the model (Cannot read properties of undefined (reading 'extensions')) and the model is fine.

I'll be straight about the limit of my own understanding here: I know the error I saw and I know switching format made it go away, but I could not afterwards trace the exact line that produces that particular TypeError. Every .extensions read in the installed GLTFLoader looks properly guarded, and a plain decoder 404 should surface as a fetch rejection rather than this. So treat "the decoder fetch causes this specific message" as my inference rather than something I've proven. The actionable part (check the decoder before you blame the model) holds either way.

the fix: recompress the GLB with meshopt

The fix was to stop compressing with the format whose decoder lives somewhere else. One command per model with the glTF-Transform CLI:

npx @gltf-transform/cli optimize in.glb out.glb \
  --compress meshopt --texture-size 512 --texture-compress webp

That does three useful things at once. Meshopt geometry, textures capped at 512px, textures re-encoded to WebP. Textures are usually the real weight in a model anyway, and EXT_texture_webp loads fine in current three.js. (In my case the WebP step only actually took on the toycar; the plane and planet came out the other side still carrying PNG, which I didn't notice at the time and which leaves the plane's 264 KB texture as the biggest remaining win in the set.)

Results across the set, all decimal MB/KB so the numbers reconcile:

  • toycar. 1.99 MB as Draco, 744 KB as meshopt
  • plane (272 KB as Draco, 296 KB as meshopt) it got bigger
  • planet (14 KB as Draco, 23 KB as meshopt) bigger again
  • the whole set went from 2.27 MB down to 1.06 MB

Now, the honest reading of that table, which took me a while to see. Almost the entire saving is the toycar, and almost the entire toycar saving is textures: 1.74 MB of PNG became 124 KB of 512px WebP. The geometry actually got bigger going from Draco to meshopt. 253 KB to 620 KB on that model, and proportionally worse on the two small ones.

So "Draco compresses harder" isn't a myth I busted. It's true, it was true here, and I gave up real bytes to leave it. I made that trade for a decoder that's already in my bundle rather than on a CDN, and I'd make it again. But it's a trade, not a free win, and anyone reading a compression post deserves to be told which one they're getting.

Worth saying plainly: this is not an argument that Draco is bad. It's an argument that you should know which decoder your loader reaches for by default, and whether that decoder is in your bundle or on someone else's server. If you're set up to serve the Draco decoder yourself, Draco is fine. Mine wasn't, and I hadn't noticed I was making that choice.

the other bug hiding underneath

While I was in there I found the models were also intermittently failing on my local preview, and not always the same one. Reload, different model missing. That smells like a race rather than a format problem.

It was my dev server. A plain single-threaded Python static server handles one request at a time, and three GLB requests firing in parallel meant queuing. With the larger files intermittently failing or arriving truncated. One line fixes it:

class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
    ...

Two completely unrelated faults producing one symptom, taking it in turns. That's why it took so long. Every time I proved one theory wrong, the other one was there keeping the bug alive, and I'd conclude I must have fixed nothing. Deeply annoying, and entirely my own doing.

The lesson I'd actually keep: when a bug is intermittent and format-related, there's a good chance it's two bugs wearing one coat, and the intermittent half is usually your environment rather than your data.

Somewhere in the same stretch I also hit a source file that glTF-Transform refused to open at all. I don't have it any more to show you what was wrong with it, so take the diagnosis loosely. But the lesson stuck: if your optimiser won't even parse a file, stop trying to fix that file and go find a clean source. I switched to the Khronos ToyCar sample, which is CC0 and known-good, and recompressed that instead.

the checklist I'd give past me

  • If a model won't load, check whether your loader is fetching a decoder from somewhere before you touch the model.
  • Prefer the compression format whose decoder ships in your bundle. For drei / three.js that's meshopt.
  • Compress textures in the same pass as geometry. They're usually the bulk of the file.
  • If failures are intermittent, suspect your server before your assets.
  • If a tool can't parse your file, get a different file. Don't debug a corrupt one.

And a closing note on that <Suspense> fallback, because I described it wrongly to myself for months. A Suspense fallback covers loading, not failure. If a load genuinely rejects, the error propagates up to the nearest error boundary and takes the whole canvas with it. What I actually had was models suspending indefinitely rather than erroring, which is why the stand-ins sat there looking permanent and content.

Comforting to look at, and completely silent. If you want a real failure state you have to build one; Suspense will not give you a graceful degrade for free, however much it looks like it did.

ob

Written by Oliver

I build browser games and simulations on my own, everything here runs in a tab, with no installer and no account. The biggest is Oliver's Racers: procedural circuits in Godot 4, online multiplayer relayed by a Raspberry Pi in my room, and an Android build. Almost nothing here is imported artwork; the cars, trees and grandstands are built out of boxes and cylinders in code at load time.

More about me · See the projects