We had two WebAssembly modules and one delivery slot, and the obvious solution cost us a detour of being wrong in an educational way.

Why can't you merge two compiled WebAssembly modules into one? Each independently linked module owns its own linear memory — data segments, stack, and heap laid out at link time. A post-hoc merge can only put both memories into one module, and many embedded runtimes instantiate at most one memory per module.

Some context on why we had two. Patch ships OTA updates to native Swift iOS apps by compiling changed Swift to WASM and running it in an interpreter on the device. The base pipeline produces one module; our additive modes (real-source compilation, SwiftUI lowering) produce a second module compiled with different settings against a different slice of the app. Both need to arrive together. One file would be tidy: one download, one hash, one artifact to track. The WebAssembly toolbox even has a tool whose name promises exactly this — wasm-merge, from Binaryen: "Merges multiple wasm files into a single file, connecting corresponding imports to exports as it does so."

So we merged them. The output was a perfectly valid-looking .wasm file. Then our runtime refused to instantiate it.

Why wasm-merge produces a module with multiple memories

The problem is the thing WASM tutorials mention once and move past: linear memory belongs to the module. A Swift module compiled to WASM doesn't just bring code — it brings its own memory, with its own data segments at fixed offsets, its own stack region, its own malloc arena, and a globals layout the compiler baked in at link time. Two independently linked Swift modules are two complete address spaces.

wasm-merge is honest about this: it doesn't (and can't safely) fuse two address spaces, so the merged module simply declares two memories. That's legal in the spec these days — multi-memory was standardized in Wasm 3.0 (September 2025) — but "in the spec" and "in your runtime" are different places: support is uneven outside the big engines, and WasmKit, the interpreter we ship on iOS, doesn't implement it. It rejected the two-memory module outright.

And even where multi-memory is supported, the merge gives you exactly what it says on the tin: two address spaces in one file. wasm-merge dutifully re-indexes every load and store back to its own original memory, so the module runs — but a pointer from one half is meaningless in the other. You've merged the file, not the program.

Binaryen offers a workaround for single-memory runtimes, by name: wasm-opt --multi-memory-lowering, which folds the second memory's data and accesses into free space in the first. We tried it. It runs — and we convinced ourselves it's unsound for modules we don't control: two mallocs each believing they own the heap, stack regions placed by two linkers that never met, baked-in pointer values that are only correct under the original layout. We never observed corruption in our tests; we rejected it because we couldn't guarantee the layout invariants for arbitrary customer code, and we ship into other people's production apps. "Works until it doesn't" is another way of spelling "doesn't."

wasm-ld vs wasm-merge: linking vs merging

The underlying lesson, which feels obvious only afterward: wasm-ld links, wasm-merge staples. If you want two bodies of code in one address space, that decision has to happen at link time, where the linker can lay out one data section, one stack, one heap, and resolve symbols into a single coherent layout. By the time you have two finished .wasm modules, that train has left. Post-hoc merging is a packaging decision wearing a linker's costume.

Once we said it that way, the answer was obvious: we wanted packaging, so we built packaging. (Why didn't we start there? We'd assumed cross-module calls were coming, so one shared address space looked load-bearing. It wasn't — the sub-modules patch different functions and are independent by construction.)

The container

What ships now is a deliberately boring format we call PMOD: a container holding the default module plus any additive sub-modules, each entry indexed, the whole thing brotli-compressed and hash-verified as a unit before anything activates. On device, the SDK instantiates each sub-module as its own WasmKit instance — its own memory, its own world — and routes each patched function's calls to the instance that exports it. When a patched function calls another patched function, that call exits to the host like any call into the app, so it routes to the right instance the same way the first one did.

What we gave up: a shared address space we turned out not to need. What it costs: duplication — each instance carries its own copy of its module's data segments and its own heap, memory you pay per sub-module, which we accepted as the price of layouts that are actually correct. What we got: every module keeps the exact memory layout its linker produced, no rebasing, no shared-heap roulette, and a failure model where one bad sub-module can't corrupt another — it just fails its own instantiation and the SDK's fallback chain handles it.

The general shape, for anyone hitting the same wall:

You wantDo thisNot this
Two codebases, one address spacecompile together, link with wasm-ldwasm-merge after the fact
Two finished modules, one artifacta container + N instancesone module with N memories
Modules talking to each otherroute through host importsshared linear memory

What about the WebAssembly Component Model?

The Component Model reaches the same conclusion from the standards side: components are shared-nothing — each core module keeps its own linear memory, and cross-component calls go through typed interfaces, not a shared heap. Our container is the same shape with less machinery; the interpreters available on iOS don't run components today, so we kept the boring version.

The takeaway

WebAssembly's model is genuinely good here — the module is the isolation boundary, and the moment you fight that, you're rebuilding a worse process model inside someone else's address space. Ship more instances, not bigger modules.


Patch ships OTA code updates for native Swift iOS apps — the container and dual-instance routing described here run in its public on-device SDK. Related: Compiling Swift to WebAssembly · WasmKit: the WebAssembly runtime written in Swift · What Apple actually allows