Why we shipped with WASM first, why we then switched to native a day later, and what the performance numbers actually look like. A small case study in not over-optimizing on day one.
The upstream codec ships in two forms:
- A WASM module (
dss-codecon npm) — usable from Node.js or a browser. - A native Rust crate that compiles to a standalone CLI binary.
When we shipped this pipeline, we started with the WASM chain. A day later we switched to native. This chapter explains why both, why in that order, and what we measured.
The WASM module is what we discovered first. The upstream wrapper repo is structured around it — there's a polished demo, a published npm package, well-documented Node.js entry points. It works in five minutes:
npm install dss-codec @breezystack/lamejs
node script.mjs input.ds2A first-cut conversion pipeline in a single Node script. No build tools, no compiler, no system dependencies beyond node itself.
For a project where the goal is to know if the codec works on our real data, that's exactly what you want. You don't want to spend a day setting up Rust toolchains before you've even run a single conversion on a real file. So we shipped the WASM chain, validated 35 real files, ran the A/B against Switch, and put it in production.
It worked. End-to-end. Real production traffic. Whisper-equivalent quality.
The WASM chain has one operational quirk: it's slow.
Test file: 31.8-minute DS2 QP dictation, 6.4 MB
WASM chain (Node + dss-codec + lamejs):
decode + MP3 encode = 33 seconds total
Native chain (dss-decode-native + ffmpeg/libmp3lame):
decode (6 s) + MP3 encode (~0.5 s) = ~10 seconds total
~3.3× faster on the same file, on the same hardware. For a single conversion, the difference is 23 seconds — not earth-shattering. For a backlog (we had 22 stuck files when we first turned the new system on), it would have been the difference between 12 minutes and 4 minutes of catch-up time. Still manageable. But:
- Under load (50 files arriving in a burst), 50 × 33 s = 27 minutes vs 50 × 10 s = 8 minutes. That matters.
- Lower CPU consumption per file frees the server for other things.
- A 5 MB native binary with libc-only dependencies is operationally simpler than a Node runtime + 15 npm packages.
- Switching is reversible in one line: the wrapper bash script changes from
exec node ...toexec dss-decode-native ....
So we switched, on day two. Took ~3 hours including the validation re-run.
Two main sources, in order of magnitude:
The WASM chain uses lamejs — an MP3 encoder rewritten in pure JavaScript by the lame.js authors. It's a faithful port of the C LAME encoder, but it's pure JS. No native code, no SIMD, no asm.
The native chain uses libmp3lame via ffmpeg — the original C LAME encoder, with decades of hand-tuned assembly for x86_64 SIMD instructions (SSE, SSE2, AVX).
On a 31-minute speech file:
- lamejs: ~20-25 seconds to encode to 64 kbps mono
- libmp3lame: ~0.3-0.5 seconds to encode the same
That's a ~50× speedup on the encoder step alone. About 70 % of the WASM chain's total wall-clock comes from this single bottleneck.
The WASM-compiled Rust decoder runs in V8's WebAssembly VM. WASM's SIMD support is limited to 128-bit operations (roughly equivalent to SSE2 from the early 2000s) and adds bounds-checks on every memory access. The native binary compiles for the host CPU with all available instruction sets (AVX2 on a modern Ryzen) and skips the bounds checks.
For a CELP decoder — which is dominated by short floating-point multiply-accumulate loops over the synthesis filter and the codebook — AVX2 vs SSE2 is a real win.
- WASM decoder: ~10-12 seconds for the 31-min file
- Native decoder: ~6 seconds for the same
About 2× speedup on decode. Less dramatic than the encoder, but it adds up.
After the switch, the wrapper bash script's interface is identical. Same arguments, same exit codes, same output format. The cron didn't need to know anything had changed. The web UI didn't need to know. The downstream pipeline didn't need to know.
Internally:
- Before: bash wrapper →
node lib/cli.mjs→ importsdss-codecWASM → runs in-process → callslamejs(JS) → writes MP3. - After: bash wrapper →
dss-decode-native(subprocess) → writes WAV → ffmpeg (subprocess) → writes MP3.
Two extra fork() calls per conversion (spawning the binaries vs running in-process). On a modern Linux, fork() is ~5 ms. Compared to the 10-second conversion, completely negligible.
The web UI's PHP backend calls a small Node.js HTTP daemon for synchronous on-demand conversions. After the switch to native, the daemon kept using Node — it just spawns the native binary as a subprocess for each request, instead of running the WASM in-process.
Why? Because Node is already a perfectly fine HTTP server, and rewriting the daemon in Go or Rust to "remove" the Node dependency would have been weeks of work for zero observable benefit. The daemon is a 100-line bridge that handles request routing and subprocess management; it's the wrong place to optimize.
The WASM version is still on disk:
lib/core-wasm.mjs.bak # the JS module that called the WASM directly
bin/conv-dss-ds2-to-mp3.wasm.bak # the bash wrapper for the WASM chain
A single mv switches back. We've never needed to. But the option is there, for the same reason every safety net in this project is there — because production systems eventually surprise you, and the rollback path should already exist when they do.
This project ended up native because we control our deployment environment (one Linux server farm, x86_64). For other use cases, WASM is the obvious choice:
- Browser-side conversion — no native binary will ever run client-side; WASM is the only option.
- Cross-platform tools — a Node CLI distributed via npm is portable across Linux/macOS/Windows/ARM with zero per-platform builds. The native binary requires one build per target architecture.
- Embedded in a larger Node application — sometimes you just want to convert a DS2 in the middle of an existing JS pipeline without spawning external processes.
- Sandboxed environments — serverless functions, edge workers, or any place native binaries are difficult to deploy.
The fact that we can run native here is a consequence of our setup. If you're shipping a SaaS where users upload DS2 files in their browser, WASM is the correct choice; the speed difference is irrelevant compared to the network round-trip.
| Scenario | Use |
|---|---|
| Linux server, batch processing, perf matters | Native binary |
| Browser-side conversion | WASM |
| Cross-platform Node CLI | WASM (npm install just works everywhere) |
| Embedded in existing Node app | WASM (no subprocess) |
| Serverless / edge function | WASM (no native binaries allowed) |
| Container-based microservice | Either — native if perf matters, WASM if simpler deploy matters |
There's a principle this story illustrates that's worth naming.
Ship the simple thing first, even if it's not the fastest. Measure. Only then optimize.
If we'd insisted on going native on day one, we'd have spent the first day fighting cargo, build-essential, the linker, and the Rust toolchain instead of validating that the codec produces correct audio. The WASM chain let us answer the risky question first: does this thing actually work on our data?
Once "yes, it works" was settled, switching to native was a contained optimization — same interface, same outputs, just faster. Three hours of work, including the re-validation. No drama.
Doing it the other way around would have meant fighting the perf-tuning toolchain while also being uncertain whether the codec would ultimately work. Two unknowns at the same time is always worse than one then the other.
Next: 05 — Lessons learned — the bugs we ate, the gotchas we got bitten by, and the operational quirks worth knowing if you do this for your own pipeline.
Five times the speed for the price of one rewrite. ⚡