If you have ever served a detailed 3D model over the web, you already know that geometry is heavy. A nicely sculpted mesh can run into tens of megabytes before you have even thought about textures, and every one of those bytes has to crawl down the wire before anything appears on screen. Google's Draco exists to fix exactly this. It is a compression codec for meshes and point clouds that routinely shrinks geometry by ninety percent or more, trading a little client-side decode time for a dramatically smaller download. The catch has always been the ergonomics. The official draco3d module is a faithful Emscripten port of a C++ library, which means you spend your day allocating typed-array buffers, pushing them onto point-cloud objects, reading status codes, and dutifully calling destroy() on every wrapper so you do not leak WASM memory.
draco.js (npm: draco.js) is a small answer to that pain. It is a JavaScript wrapper and a set of TypeScript type definitions that sit on top of the very same Draco WASM build and expose it as two friendly, Promise-based classes: DracoEncoder and DracoDecoder. You hand it an ArrayBuffer, you get back plain typed arrays. You hand it plain typed arrays, you get back a compressed .drc buffer. The compiled WASM ships inside the package, so there is nothing to fetch or build, and the whole thing has zero runtime dependencies. It is Apache-2.0 licensed, the same as Draco itself.
A quick word on names before we go further, because the 3D ecosystem is crowded with similar ones. This package is draco.js by Howard Yin. It is not draco3d, which is Google's official low-level module, and it is not three.js's DRACOLoader, which is the browser-side decoder most people meet first. We will come back to how all three relate, because choosing between them is half the value of understanding this library.
What the Wrapper Actually Gives You
The headline is ergonomics, not raw capability. Under the hood you are running the exact same Draco codec that powers the official tooling, so the compression ratios and quality are identical. What changes is the surface you touch.
First, both operations are asynchronous. decode() and encode() return Promises, which lines up neatly with how the WASM module initializes itself in the background. You simply await the result instead of wiring up a ready callback and then carefully sequencing your work after it.
Second, decoded geometry comes back as ordinary JavaScript. You get a numPoints count, a numFaces count, an indices array, and an attributes object whose keys are familiar semantic channels: POSITION, NORMAL, COLOR, TEX_COORD, and GENERIC. Each channel is a standard TypedArray, so you can feed it straight into a WebGL or WebGPU buffer, write it to a file, or transform it however you like. There is no Draco-specific geometry object to unwrap.
Third, and this is the quiet hero, the wrapper hides the memory dance. With the raw module you must destroy() every intermediate object or your application slowly bleeds WASM heap. With draco.js that allocate-and-free bookkeeping happens inside the classes, out of your sight. For a long-running encoder in an asset pipeline, that alone is worth the price of admission.
Finally, it ships typed. Because the type definitions live in the package, your editor knows what DecodedGeometry looks like, which attributes might be present, and exactly what each encoder knob expects. That turns a lot of runtime guesswork into compile-time certainty.
Getting It Into Your Project
Installation is the usual one-liner, and because the WASM is bundled there is no extra decoder path to configure.
npm install draco.js
Or, if yarn is your tool of choice:
yarn add draco.js
That is genuinely all of it. There is no separate setDecoderPath() step and no companion file to host, because the encoder and decoder .wasm binaries travel inside the package itself.
Turning a .drc File Back Into Geometry
The most common starting point is reading an existing compressed file and getting usable arrays out of it. Here we read a .drc file from disk in Node, decode it, and inspect what came back.
import * as fs from "fs";
import { DracoDecoder } from "draco.js";
async function inspectMesh() {
const inputBuffer = await fs.promises.readFile("./bunny.drc");
console.log("Compressed size:", inputBuffer.byteLength, "bytes");
const decoder = new DracoDecoder();
const geometry = await decoder.decode(inputBuffer);
console.log("Points:", geometry.numPoints);
console.log("Faces:", geometry.numFaces);
console.log("Attributes:", Object.keys(geometry.attributes));
const positions = geometry.attributes.POSITION; // a Float32Array
console.log("First vertex:", positions?.[0], positions?.[1], positions?.[2]);
}
inspectMesh();
The shape of geometry is the DecodedGeometry interface: a points count, a faces count, an indices array that is either a Uint32Array or null, and the attributes bag of typed arrays. Notice that you never touched a Draco internal object or freed anything. The decoder allocated whatever it needed during decode() and cleaned up before resolving the Promise.
From here, the typed arrays are yours to do with as you please. In a custom renderer you would copy attributes.POSITION into a vertex buffer and indices into an index buffer. In a tooling script you might convert the whole thing to PLY or OBJ. The point is that the wrapper hands the geometry back in a format every part of the web platform already understands.
Compressing Geometry Back Down
The other direction is where draco.js earns its keep, because encoding is the half that a typical browser loader cannot do at all. Suppose you have raw geometry, either freshly authored or decoded a moment ago, and you want a compact .drc to ship.
import * as fs from "fs";
import { DracoEncoder, DracoDecoder } from "draco.js";
async function recompress() {
// Start from some geometry. Here we decode an existing file,
// but these arrays could come from anywhere.
const decoder = new DracoDecoder();
const geometry = await decoder.decode(
await fs.promises.readFile("./bunny.drc"),
);
const encoder = new DracoEncoder();
encoder.SetAttributeQuantization("POSITION", 10); // 10-bit positions
encoder.SetAttributeQuantization("COLOR", 8); // 8-bit colors
encoder.SetSpeedOptions(5, 5); // balanced speed vs ratio
const encoded = await encoder.encode(geometry);
console.log("Re-encoded size:", encoded.byteLength, "bytes");
await fs.promises.writeFile("./out.drc", Buffer.from(encoded));
}
recompress();
The encoder exposes the classic Draco tuning knobs. SetAttributeQuantization controls how many bits of precision each channel keeps, and this is your main lever on the size-versus-quality tradeoff. Positions usually want ten to fourteen bits, while colors and texture coordinates can often get away with eight. SetSpeedOptions takes an encoding speed and a decoding speed, each on a scale where higher means faster but less compressed, so (0, 0) squeezes hardest and (10, 10) runs quickest. There is also SetEncodingMethod if you want to pin a specific connectivity strategy, and SetTrackEncodedProperties if you need the encoder to report back on what it produced.
Because encode() returns a plain ArrayBuffer, writing the result is just a matter of wrapping it for whatever sink you are targeting. In Node that is Buffer.from(encoded); in the browser it might be a Blob or a fetch upload.
Reaching for the Attribute Metadata
Sometimes you need to build attribute arrays yourself rather than decode them, and that is where the third export, dracoAttributesInfo, comes in. It is a small lookup table that tells you the stride and default typed-array type for each semantic channel, so you can size your buffers correctly without memorizing that a color is three or four components wide.
import { DracoEncoder, dracoAttributesInfo } from "draco.js";
// Suppose we have positions but want to attach a synthesized color channel.
function withRandomColor(geometry: {
numPoints: number;
attributes: Record<string, ArrayLike<number>>;
}) {
const stride = dracoAttributesInfo.COLOR.stride;
const ColorType = dracoAttributesInfo.COLOR.defaultType;
const colors = ColorType.from(
Array.from({ length: stride * geometry.numPoints }, () =>
Math.floor(Math.random() * 255),
),
);
geometry.attributes.COLOR = colors;
return geometry;
}
This is exactly the pattern the project's own example uses to graft a color attribute onto an uncolored bunny before re-encoding it. The metadata keeps you honest about array sizes, and the DracoAttributesConstructor type, which you can pass to either class's constructor, lets you declare up front which TypedArray each channel should decode into. That is handy when you want positions as Float32Array but colors as Uint8Array to save space downstream.
Where draco.js Fits Among Its Cousins
Choosing the right Draco tool matters, so here is the honest map. The official draco3d package is the reference implementation: the same engine, maximum compatibility, and enormous adoption, but a raw API that makes you manage memory and write a fair amount of glue. draco.js is essentially that engine with a typed, garbage-collected wrapper bundled in, so think of it as draco3d made pleasant for TypeScript, especially when you need to encode as well as decode.
three.js's DRACOLoader is the tool most web developers actually reach for, and rightly so when the goal is to display geometry. It decodes Draco meshes, including the compressed geometry embedded in glTF files via the KHR_draco_mesh_compression extension, and returns a three.js BufferGeometry ready to drop into a scene. It is decode-only, though, and it returns three.js objects rather than plain arrays. If all you want is to show a .glb in a viewer, stay with DRACOLoader.
A different beast entirely is meshoptimizer, which compresses and optimizes geometry with its own algorithm rather than wrapping Draco. It often decodes faster and streams beautifully, and it comes with its own tooling and loaders. Draco frequently wins on raw ratio while meshopt wins on decode speed and simplicity, so it is worth a benchmark on your own assets before committing.
draco.js sits in a specific niche carved out of that landscape: a small, typed, dependency-free layer for when you need to round-trip raw geometry, encode and decode both, as plain typed arrays, without hand-rolling the draco3d boilerplate and without dragging in the full three.js stack. That makes it most at home in Node-side asset pipelines, custom WebGL or WebGPU engines, and experiments where TypeScript ergonomics matter.
The Honest Footnote
It would be unfair to send you off without the caveats. This is a young, single-author project that first appeared in May 2025 and sits at a 1.0.x line with very modest adoption. The README leans heavily toward build-from-source instructions and is light on consumer-facing docs, and a couple of API typos are baked into the public surface, most notably the GetArrtibutesType method, so spell it the way the types do. There is no browser-loader integration either; you wire the resulting arrays into your renderer yourself. None of that is disqualifying, but it does mean you should treat draco.js as a sharp, lightweight convenience for tooling and exploration rather than a hardened production dependency. For battle-tested browser display, DRACOLoader and draco3d remain the safer bets.
Conclusion
draco.js does one thing and does it cleanly: it takes Google's excellent but verbose Draco codec and wraps it in a typed, Promise-based API that returns plain typed arrays and handles the WASM memory dance for you. If you have ever winced at the draco3d boilerplate, or needed to actually produce .drc files rather than merely consume them, this little package is a refreshing shortcut. Go in knowing it is new and lightly travelled, lean on it for Node-side pipelines and custom engines, and you will spend your time thinking about geometry instead of pointers. That is a trade most of us would happily take.