Part of the engineering behind ZumiFiles.
The ask was simple enough: "can we add RAW file viewer support, here is a GitHub link." A library exists, it does the thing, wire it up. I have done a dozen integrations like this. This one took most of a day, and along the way it touched a Docker daemon, a race condition inside someone else's WebAssembly build, a dev server's dependency optimizer, and - briefly - convinced me that React itself was broken.
None of it was any single thing being badly wrong. It was six small, independently reasonable failures, each hiding behind the one before it. This is ZumiFiles, which ships as an embeddable web component dropped into other people's pages, and that one constraint is what turned an afternoon into a postmortem.
Six Lessons, One Per Failure
- Read the binary first:
strings your.wasm | grep pthreadtells you if it needs cross-origin isolation. - Isolation is top-level only: an embedded component can't set COOP/COEP on someone else's page.
- "await ready" can lie: a promise may resolve one microtask before the instance is usable.
- Dev servers rewrite paths: pre-bundling can break a library's
import.meta.urlWASM lookup. - Two Reacts break React: a second copy in your test rig throws "Invalid hook call."
- Single-file bundles inline WASM: forced one-file output folds your lazy binary back in.
Chapter 1: The Library That Couldn't Leave the Building
The linked repo, libraw.wasm, looked right: LibRaw compiled to WebAssembly, MIT license, does exactly what you would want. First problem: it wasn't published to npm. No releases, no tags, dist/ gitignored. The only way to get it was to build it from source.
Rather than fight that immediately, I found a second library with the same job - libraw-wasm, properly published, nicer async API, decoding in a Web Worker so it wouldn't block the UI thread. Better on paper in every way. I wired it up, shipped it, tested it. It hung. Not errored - hung. A spinner that never resolved, forever, on every RAW file.
Pulling apart the compiled output explained why: this build used Emscripten's pthreads support. Threaded WebAssembly needs SharedArrayBuffer, and browsers only hand that to pages that are cross-origin isolated - a pair of response headers (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy) the page's own server has to set. Without them the decode doesn't throw. It deadlocks silently inside a worker, waiting on an Atomics call that will never be satisfied.
Here is what made it more than a config fix. crossOriginIsolated is a property of the top-level document. ZumiFiles ships as <zumi-files>, embedded into other people's pages, and an embedded component has no way to set headers on someone else's server. I could make the standalone dev app isolated in thirty seconds. I could not make every host page that will ever embed the component isolated. The library was permanently, architecturally incompatible with the product - not a bug to patch, a wrong turn to reverse.
# Ten seconds that would have saved most of a day
strings your.wasm | grep -iE 'pthread|SharedArrayBuffer'
Chapter 2: The Library That Wouldn't Install
Back to the first library, the one with no npm release. Its CI did publish every commit to pkg.pr.new, a preview-package service - not ideal for a pinned production dependency, but real, and built by the maintainer's own pipeline. Good enough to unblock, with a plan to vendor it properly later.
"Later" turned into now, because the preview build carried a query-string dependency version that broke asset resolution downstream (Chapter 5). So: build it myself, via the documented Docker + Emscripten pipeline. Docker was installed, the daemon started fine, and docker compose build sat at "load metadata for docker.io/emscripten/emsdk" and never moved. A plain docker pull hello-world, the smallest possible image, sat for over a minute. Meanwhile a raw curl to the registry from the same shell returned in under half a second.
That is a sandboxing detail, not a Docker bug: this environment's daemon runs in its own network context, separate from the host shell's. The host could reach the internet; the container runtime could not reach the same registry the host had just proven was reachable. No amount of retrying fixes that from inside the sandbox. Fallback to the pkg.pr.new build it was - at least that path wasn't blocked by infrastructure I didn't control.
Chapter 3: The Eleven-Line Race Condition
With a build finally in hand, the decode kept throwing a bare, empty Error - no message, no stack, on the very first call, every time, only on a cold page load.
The library's LibRaw class does something subtly dangerous in its constructor: it kicks off an async setup routine but doesn't expose the promise. You are meant to call await instance.waitUntilReady() before using it. I did. It still failed.
Tracing the actual microtask order revealed the bug. waitUntilReady() awaits the static, module-level "is the WASM compiled" promise. The constructor's own setup - which assigns the instance's internal handle, the thing every method needs - awaits a related but different chain, one hop deeper. On a cold start, waitUntilReady()'s single hop resolves first. Code calls .open() on an instance whose handle is still zero. LibRaw complains, but the complaint is read from a pointer that isn't valid yet either, so the message comes back empty.
The fix was almost insultingly small: after waitUntilReady(), poll instance.status until it flips to 'ready', which happens in the same synchronous tick as the handle assignment.
await instance.waitUntilReady();
// waitUntilReady() can resolve one hop before the instance handle exists.
// Poll the status the constructor sets in the same tick as the handle:
while (instance.status !== 'ready') {
await new Promise((r) => setTimeout(r, 0));
}
// only now is instance.open() safe
Eleven lines. Finding them took reconstructing a promise-resolution diagram by hand, because nothing in the failure - a blank Error, forty-six milliseconds after start - pointed anywhere near the real cause.
Chapter 4: The Dev Server's Helpful Rewrite
Fixed the race, reran the test, new failure: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 69 6d 70 6f. Translated: something tried to compile a .wasm file and got a JavaScript file instead. 69 6d 70 6f is ASCII for impo, the start of an import statement.
The library locates its own binary at runtime with new URL("libraw.wasm", import.meta.url) - completely standard Emscripten output. Vite's dev server, though, pre-bundles dependencies into a synthetic cache directory to speed up cold starts, and a module served from that synthetic location has an import.meta.url that no longer points anywhere near the real file. The relative lookup silently resolved to the wrong neighbor.
The fix was one config line - tell Vite to leave this package alone and serve it straight from node_modules, where its relative URLs are still true:
// vite.config.js
export default {
optimizeDeps: {
exclude: ['libraw.wasm'], // don't pre-bundle; keep import.meta.url honest
},
};
Diagnosing it meant first ruling out the wasm file being corrupted, then wrong content-type headers, then a caching race, before actually diffing the resolved URL against what the browser received.
Chapter 5: The Bug That Wasn't There
One more test, mounting the component directly to sidestep the fact that this sandbox can't drive a native file picker. It rendered nothing. Console: the exact same wasm magic-word error, in a context where I had just proven that call path worked moments earlier.
An hour of chasing phantom wasm failures later, a global error listener surfaced the actual exception, and it had nothing to do with WebAssembly: "Invalid hook call." My test harness had manually imported a second, separate copy of React to drive the mount - a reasonable-looking shortcut that is one of the most reliable ways to break React from the outside. Two React copies, two disconnected internal states, a hook call that looked valid to the code but not to the runtime. The wasm errors earlier in that same console were unrelated noise from other tests, still sitting in a log buffer that, as it turned out, never clears itself between page reloads.
The real component had been fine since Chapter 3. I had spent an hour debugging my own test rig and blaming the library for it.
Chapter 6: The Binary That Wouldn't Stay Small
Last one. The production build passed every check, until I looked at the output size. The main bundle had grown by over a megabyte. The WASM binary, meant to load lazily only when someone opened a RAW file, had been inlined - base64-encoded, sitting directly in the file everyone downloads on every visit, whether they ever touch a RAW photo or not.
The bundler config forces exactly one output file, for good reason: ZumiFiles ships as a single <script> tag anyone can embed. A dynamic import() normally becomes its own lazy chunk, but with single-file output forced on, the bundler had nowhere else to put it and folded it back in. The project already had a working pattern for this, built for a much larger dependency: externalize the package, bundle it separately with esbuild, and ship the result as a same-origin file the app fetches only when actually needed. Extending that pattern was the easy part. Noticing the bundle had silently grown was the part that mattered.
What Actually Made This Hard
None of these were exotic bugs. A missing header. A registry a sandboxed daemon couldn't reach. A promise resolved one microtask too early. A dev-server optimization solving a problem I didn't have while creating one I did. A test script that broke the framework it was testing. A bundler doing exactly what it was configured to do, just not what I wanted.
What made it slow is that WebAssembly-in-the-browser sits at the intersection of four systems that don't know about each other: the browser's security model (COOP/COEP), the bundler's dependency graph (pre-bundling, code-splitting), the module's own async lifecycle, and whatever sandbox or network context the tooling runs in. Each layer's failure mode looks, from the outside, exactly like every other layer's: something didn't load, or something hung, or something threw an error with no useful message. You cannot tell which system is at fault from the symptom alone. You have to descend into each one - read the compiled binary's strings, trace the actual promise resolution order, diff the exact bytes the browser received - until the symptom stops being ambiguous.
The library was about 800 lines. The debugging was six separate systems, each innocent on its own.
If you are integrating a WASM library into something that has to be embeddable, small, and reliable on a cold load: check what it was compiled with, check whether "await ready" actually means what you think it means, and don't fully trust your dev server to hand a library the same file path production will.