If you have ever shipped a 3D model to the web, you know the pain: a beautifully detailed mesh balloons to tens of megabytes, and your users sit staring at a loading spinner while it trickles down the wire. Vertex positions, normals, UVs, colors, and the index buffer that stitches it all into triangles add up fast. Draco (npm: draco3d) is Google's answer to that problem. It is a geometry compression codec that shrinks the mesh payload of a 3D asset dramatically, often cutting geometry-heavy models by 90 to 95 percent, while keeping the visual fidelity good enough that most people will never notice the difference.
Draco shows up everywhere in the 3D web stack, usually without you noticing. It is a transitive dependency of three.js tooling, model-viewer, gltf-transform, and gltf-pipeline, which is how a single codec racks up over three million npm downloads a week. Whether you are building an e-commerce product viewer, a VR experience, a map, or a browser game, Draco is the difference between a snappy first load and an awkward wait.
One framing matters before we go further: Draco compresses download size, not GPU runtime cost. Decompression happens on the CPU before geometry is uploaded to the GPU, so it trims your transfer payload but does not make your scene render faster. If you want a higher framerate you still need fewer vertices and fewer draw calls, which is mesh simplification, a different job entirely.
What Makes Draco Tick
Draco is written in C++ and compiled to WebAssembly with a pure-JavaScript fallback, courtesy of Emscripten. The draco3d npm package ships the prebuilt artifacts so you never have to touch a build toolchain:
- Zero runtime dependencies. The package is self-contained: prebuilt JS plus WASM decoder and encoder blobs.
- A decoder and an encoder. You can decompress meshes in the browser, and you can compress them too, in Node or in the browser.
- A slim glTF decoder variant tuned for the most common web case, smaller than the full decoder.
- Lossy quantization with knobs. You trade a few bits of precision for a much smaller file, and you control exactly how aggressive that trade is.
- A standardized glTF extension. Draco is baked into the glTF 2.0 spec as
KHR_draco_mesh_compression, so any compliant loader decodes it transparently.
The WASM decoder runs roughly twice as fast as the pure-JS fallback, which is why you almost always want WASM in production. The format itself is mature and standardized, so it does not churn; version 1.5.7 has been the stable baseline for a good while.
Getting It Installed
Add the package with your manager of choice:
npm install draco3d
yarn add draco3d
Keep in mind this is not a typical featherweight JS library. It carries WASM binaries: the decoder is north of 200KB and the encoder is larger. In practice you load the decoder lazily, or pull a versioned copy from Google's gstatic CDN so it can be cached across every site that uses the same version, rather than bundling it into your app.
Unpacking a Compressed Mesh
The raw decoding flow is the most hands-on way to use Draco, and it teaches you what the higher-level tools do for you. You instantiate the decoder module, wrap your compressed bytes in a buffer, decode them into a mesh, and pull out the attributes you care about.
import createDecoderModule from "draco3d/draco_decoder_nodejs.js";
async function decodeMesh(compressed: Uint8Array): Promise<Float32Array> {
const decoder = await createDecoderModule();
const buffer = new decoder.DecoderBuffer();
buffer.Init(compressed, compressed.length);
const dracoDecoder = new decoder.Decoder();
const mesh = new decoder.Mesh();
dracoDecoder.DecodeBufferToMesh(buffer, mesh);
const attrId = dracoDecoder.GetAttributeId(mesh, decoder.POSITION);
const attr = dracoDecoder.GetAttribute(mesh, attrId);
const numPoints = mesh.num_points();
const positions = new Float32Array(numPoints * 3);
const dracoArray = new decoder.DracoFloat32Array();
dracoDecoder.GetAttributeFloatForAllPoints(mesh, attr, dracoArray);
for (let i = 0; i < numPoints * 3; i++) {
positions[i] = dracoArray.GetValue(i);
}
decoder.destroy(dracoArray);
decoder.destroy(mesh);
decoder.destroy(dracoDecoder);
decoder.destroy(buffer);
return positions;
}
The detail you cannot skip is at the bottom. Draco lives on an Emscripten heap, and JavaScript's garbage collector knows nothing about it. Every C++ object you create with new must be released with decoder.destroy(...). Forget this and you leak memory steadily until the tab falls over. This is the single most common gotcha when working with the raw API, so make freeing objects a reflex rather than an afterthought.
Compressing a Mesh Yourself
Going the other direction, you feed attributes into a MeshBuilder, configure quantization on an encoder, and emit a Draco buffer.
import createEncoderModule from "draco3d/draco_encoder_nodejs.js";
async function encodeMesh(
positions: Float32Array,
indices: Uint32Array,
): Promise<Uint8Array> {
const encoder = await createEncoderModule();
const builder = new encoder.MeshBuilder();
const mesh = new encoder.Mesh();
const numFaces = indices.length / 3;
const numPoints = positions.length / 3;
builder.AddFacesToMesh(mesh, numFaces, indices);
builder.AddFloatAttributeToMesh(
mesh,
encoder.POSITION,
numPoints,
3,
positions,
);
const dracoEncoder = new encoder.Encoder();
dracoEncoder.SetAttributeQuantization(encoder.POSITION, 11);
dracoEncoder.SetSpeedOptions(5, 5);
const out = new encoder.DracoInt8Array();
const size = dracoEncoder.EncodeMeshToDracoBuffer(mesh, out);
const result = new Uint8Array(size);
for (let i = 0; i < size; i++) result[i] = out.GetValue(i);
encoder.destroy(out);
encoder.destroy(mesh);
encoder.destroy(builder);
encoder.destroy(dracoEncoder);
return result;
}
Two settings dominate the quality-versus-size trade. Quantization bits snap each floating-point attribute onto a fixed integer grid; the position default of 11 bits is a sweet spot most projects tolerate with no visible loss, while normals and UVs often survive at 8 to 10 bits. Compression level, from 0 to 10 with a default of 7, is an entropy-coding effort dial: higher means smaller files but slower decode, and it does not affect quantization error.
Because quantization is lossy, treat Draco as the last step in an asset pipeline and always keep the uncompressed source. Decompressing and recompressing repeatedly grinds away precision. Push quantization too low and you start to see cracks at mesh seams, especially along UV seams, plus shimmering normals.
Letting glTF Do the Heavy Lifting
Most real-world Draco usage never touches the raw API. Instead, Draco rides inside glTF as the KHR_draco_mesh_compression extension, and the tooling handles encoding for you. The cleanest modern option is glTF-Transform, which compresses geometry with Draco and textures with WebP or KTX2 in one command:
gltf-transform optimize input.glb output.glb \
--compress draco \
--texture-compress webp
That distinction is worth burning into memory: Draco only compresses geometry. Textures are frequently the larger half of an asset, and Draco ignores them entirely. A genuinely optimized model pairs Draco (or its rival, meshopt) for geometry with KTX2 or WebP for textures. The older CesiumGS gltf-pipeline also works (gltf-pipeline -i model.gltf -o model.glb -d), but glTF-Transform's optimize doing both jobs at once is hard to beat.
The three.js and React Path
On the rendering side, three.js ships DRACOLoader, which plugs into GLTFLoader and decodes compressed primitives lazily in a Web Worker pool so the main thread stays smooth.
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(
"https://www.gstatic.com/draco/versioned/decoders/1.5.7/",
);
dracoLoader.setDecoderConfig({ type: "wasm" });
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
loader.load("/model.glb", (gltf) => scene.add(gltf.scene));
Create one DRACOLoader and reuse it across every model rather than spinning up a fresh one each time, and call dracoLoader.dispose() when you are finished to release the worker pool. Keep the bundled decoder version aligned with your draco3d version to avoid decode mismatches.
If you live in React Three Fiber, life is even easier. The useGLTF hook from @react-three/drei handles Draco automatically, defaulting to the gstatic CDN and only fetching the decoder when a model actually uses compression:
import { useGLTF } from "@react-three/drei";
function Shoe() {
const { nodes, materials } = useGLTF("/shoe-draco.glb");
return (
<mesh geometry={nodes.shoe.geometry} material={materials.leather} />
);
}
useGLTF.preload("/shoe-draco.glb");
For most people this simply works with zero configuration. If you need to self-host the decoder for offline use or to avoid a third-party CDN, copy the decoder files into your public/draco/ folder and call useGLTF.setDecoderPath("/draco/"). And if you would rather not hand-write the component, npx gltfjsx model.glb generates a typed R3F component straight from a Draco GLB and wires up useGLTF for you.
When to Reach for Something Else
Draco is not the only game in town. The main rival is meshoptimizer, exposed in glTF as EXT_meshopt_compression. Its compression ratio is sometimes a touch worse than Draco's, but it decodes far faster with a smaller, simpler decoder, which makes it increasingly popular for runtime-critical apps. Conveniently, glTF-Transform supports both, so you can A/B test the two on your own assets and pick the winner. As a rough heuristic: favor Draco when the smallest possible download is paramount, and lean toward meshopt when decode latency matters more than the last few percent of size.
Wrapping Up
Draco earns its place at the heart of the 3D web by doing one thing exceptionally well: making geometry small. Whether you wield the raw decoder and encoder, let glTF-Transform bake compression into your pipeline, or simply drop a Draco GLB into useGLTF and watch it load, the payoff is the same dramatic drop in transfer size. Remember the three rules that keep you out of trouble: free your Emscripten objects, treat compression as the final pipeline step on a preserved source, and pair Draco's geometry savings with proper texture compression. Do that, and your beautifully detailed models will reach your users before their patience runs out.