# How to share one implementation across languages with WebAssembly

> Compile one Rust core to WebAssembly, call it from Node, Python, and Go through thin wrappers, and measure what the boundary costs before you retire a port.

Source: https://voxgig.com/howto/share-one-implementation-across-languages-with-wasm

- Audience: platform-team
- Level: advanced
- Languages: rust, typescript, javascript, python, go
- Verified: 2026-10-21
- Published: 2026-10-20

## Short answer

Compile the core once with Rust to a WebAssembly module that exports `alloc`, `dealloc`, and one function per operation over UTF-8 bytes, so every host language runs the same code. Wrap it once per host, with Node's built-in `WebAssembly` object, the `wasmtime` package in Python, and the `wazero` module in Go. Each call copies its arguments in and its result out, so measure the boundary before you retire a port, and batch small calls into one.

---
## You will need

Rust with the `wasm32-unknown-unknown` target, Node 22 or later, Python 3.13 or later with the
`wasmtime` package, and Go 1.24 or later with the `wazero` module. Verified 2026-10-21 against
Rust 1.97.0, Node 22.22.0, Python 3.13.16, Go 1.24.7, `wasmtime` 49.0.0, and `wazero` 1.11.0. In
the sample directory, `npm ci` adds the target, builds the module, and installs `wasmtime` into
`pylib/`.

The core is one function that cuts a string to a number of characters and appends an ellipsis.
The sample calls it from three hosts and measures it from one. It does not cover WASI, the
component model, or a browser build.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Extism plug-ins](https://extism.org/docs/overview/) | Many host languages, and you would rather adopt a calling convention than design one | A host SDK in every language you ship, and every call copies its input into memory Extism manages | Your only hosts are JavaScript runtimes, where generated glue is the shorter path |
| [Hand-written ports checked by a shared corpus](https://github.com/json-schema-org/JSON-Schema-Test-Suite) | Each language has a maintainer who wants idiomatic code to step through | Every behavior change is written once per language, and drift survives wherever the corpus has no case | Nobody owns a port, or the logic is subtle enough that each copy is a second place for bugs |
| [Native bindings through PyO3, napi-rs, UniFFI, or `cgo`](https://pyo3.rs/v0.30.1/) | Calls are small and frequent, and you can build for every platform your users run | A compiled artifact per platform and language, and a crash in native code takes the host process down | You cannot publish prebuilt native packages, or you want the core isolated from the host |
| [Raw WebAssembly exports with a wrapper per host](https://doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html) | A small API of strings and numbers, and hosts that each have a WebAssembly runtime | A wrapper per host language that copies every argument, and buffers you allocate and free by hand | Arguments are rich objects, or the hot path makes many tiny calls |
| [wasm-bindgen with a thin wrapper elsewhere](https://wasm-bindgen.github.io/wasm-bindgen/reference/deployment.html) | Browsers and Node are the main hosts, and you want strings, objects, and classes handled for you | Generated glue for JavaScript runtimes only, so Python and Go still need a hand-written wrapper | Python or Go is a first-class host |

Extism standardizes the wrapper this page writes by hand, and its
[memory model](https://extism.org/docs/concepts/memory/) copies input into a buffer it manages,
from which the plug-in may copy it again. wasm-bindgen generates better glue than a hand-written
wrapper, but only for JavaScript runtimes. Native bindings run inside the host process with no
sandbox between them, so a crash in the core is a crash in the host.[^1] Hand-written ports add no
runtime, but every change is written once per language.

## Export a byte-level boundary from the Rust core

The boundary is the design decision, so keep it to what every runtime can pass: integers, and
pointers into the module's memory. A module that exchanges nothing richer needs no generated
bindings, which is what lets one file serve three host languages.

```rust title="src/lib.rs"
/// Reserve `len` bytes inside the module for the host to write into.
#[no_mangle]
pub extern "C" fn alloc(len: usize) -> *mut u8 {
    Box::into_raw(vec![0u8; len].into_boxed_slice()) as *mut u8
}

/// Release a buffer from `alloc`, or a result unpacked from a `truncate_*` call.
///
/// # Safety
/// `ptr` and `len` must describe a buffer this module handed out and that has
/// not been released already.
#[no_mangle]
pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: usize) {
    drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len)));
}

/// Truncate one UTF-8 string. Returns the result's pointer in the high 32 bits
/// and its length in the low 32, or 0 when the input is not valid UTF-8.
///
/// # Safety
/// `ptr` and `len` must describe `len` readable bytes inside this module.
#[no_mangle]
pub unsafe extern "C" fn truncate_utf8(ptr: *const u8, len: usize, max: u32) -> u64 {
    let bytes = std::slice::from_raw_parts(ptr, len);
    match std::str::from_utf8(bytes) {
        Ok(text) => pack(truncate(text, max as usize).into_bytes()),
        Err(_) => 0,
    }
}
```

`truncate_utf8` borrows the input, packs the result's pointer and length into one `u64`, and
returns 0 for bytes that are not UTF-8 instead of panicking. The host owns both buffers from its
side: it allocates the input, and it frees the result once it has copied it out.

Build the module and list what it offers a host:

```bash
cargo build --quiet --release --target wasm32-unknown-unknown && node inspect.mjs
```

```text output
exports
  alloc           function
  dealloc         function
  memory          memory
  truncate_batch  function
  truncate_utf8   function
imports: none
```

The module asks its host for nothing. Rust documents the `wasm32-unknown-unknown` target as one
that [does not import any functions from the host](https://doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html)
for the standard library, so Node, `wasmtime`, and `wazero` each instantiate it with an empty
import list.[^2]

## Wrap the module once in each host

Every host repeats the same five steps: encode the string, ask the module for a buffer, copy the
bytes in, call the function, and copy the result out before freeing it. The Node wrapper:

```js title="core.mjs"
// Take a fresh view on every access: alloc can grow memory, which detaches any view made before it.
const memory = () => new Uint8Array(wasm.memory.buffer)

// A wasm32 pointer is a u32, but JavaScript reads i32 and i64 results as signed, so a pointer
// above 2 GiB would come back negative. >>> 0 and BigInt.asUintN read them unsigned.
function copyIn(bytes) {
  const ptr = wasm.alloc(bytes.length) >>> 0
  memory().set(bytes, ptr)
  boundary.crossings += 1
  boundary.bytesIn += bytes.length
  return ptr
}

function copyOut(packed) {
  if (packed === 0n) throw new Error('textcore: input was not valid UTF-8')
  const bits = BigInt.asUintN(64, packed)
  const ptr = Number(bits >> 32n)
  const len = Number(bits & 0xffffffffn)
  const bytes = memory().slice(ptr, ptr + len)
  wasm.dealloc(ptr, len)
  boundary.crossings += 1
  boundary.bytesOut += len
  return bytes
}

/** Truncate one string to `max` characters. Four calls into the module and two copies, every time. */
export function truncate(text, max) {
  const input = encoder.encode(text)
  const ptr = copyIn(input)
  const packed = wasm.truncate_utf8(ptr, input.length, max)
  wasm.dealloc(ptr, input.length)
  boundary.crossings += 2
  return decoder.decode(copyOut(packed))
}
```

Three details in it carry the weight. `input.length` is the byte count of the encoded string,
never `text.length`, which counts UTF-16 units. `memory()` builds a fresh view on every access,
because `alloc` can grow memory, and
[growing it detaches](https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface/Memory/grow)
every view made earlier.[^3] JavaScript reads the `i32` from `alloc` and the packed `i64` as
signed, so the wrapper reads both unsigned, or a pointer past 2 GiB comes back negative.

The Python wrapper is the same function, written against
[`wasmtime`](https://bytecodealliance.github.io/wasmtime-py/):

```py title="core.py"
def truncate(text: str, max_chars: int) -> str:
    """Truncate `text` to `max_chars` characters with the shared implementation."""
    data = text.encode("utf-8")
    # wasmtime returns i32 and i64 results as signed ints. Mask both to read the u32 pointer and
    # the packed u64, or a pointer above 2 GiB comes back negative and the write lands elsewhere.
    ptr = _alloc(store, len(data)) & 0xFFFF_FFFF
    _memory.write(store, data, ptr)
    packed = _truncate_utf8(store, ptr, len(data), max_chars) & 0xFFFF_FFFF_FFFF_FFFF
    _dealloc(store, ptr, len(data))
    if packed == 0:
        raise ValueError("textcore: input was not valid UTF-8")
    out_ptr, out_len = packed >> 32, packed & 0xFFFF_FFFF
    out = _memory.read(store, out_ptr, out_ptr + out_len)
    _dealloc(store, out_ptr, out_len)
    return out.decode("utf-8")
```

`main.go` does the same through [`wazero`](https://pkg.go.dev/github.com/tetratelabs/wazero), a
runtime that does not rely on `cgo`, so the Go host needs no C toolchain. Run the six shared cases through all three hosts:

```bash
node cases.mjs && PYTHONPATH=pylib python3 cases.py && go run .
```

```text output
node    6 of 6 cases match   "🎉 launch day" -> "🎉…"
python  6 of 6 cases match   "🎉 launch day" -> "🎉…"
go      6 of 6 cases match   "🎉 launch day" -> "🎉…"
```

The emoji case is the one the ports disagreed on. All three hosts return the whole emoji followed
by the ellipsis, because the code that decides what a character is runs in the module rather than
in each host.

## Measure the boundary before you retire a port

The call into the module is cheap, but the copying around it is not. Each `truncate` call encodes
a string, copies it in, copies the result out, and decodes it, for a function that reads a few
dozen characters. The benchmark runs 100,000 truncations four ways, and the test runner times
each:

```bash
node --test bench.test.mjs
```

```text output
# Subtest: native JavaScript: 100000 calls
ok 1 - native JavaScript: 100000 calls
  ---
  duration_ms: 81.686474
  type: 'test'
  ...
# Subtest: wasm, one call per label: 100000 calls
ok 2 - wasm, one call per label: 100000 calls
  ---
  duration_ms: 135.933701
  type: 'test'
  ...
# Subtest: wasm, the same crossings with no bytes copied: 400000 calls
ok 3 - wasm, the same crossings with no bytes copied: 400000 calls
  ---
  duration_ms: 10.032463
  type: 'test'
  ...
# Subtest: wasm, one call per batch: 5 calls
ok 4 - wasm, one call per batch: 5 calls
  ---
  duration_ms: 52.957287
  type: 'test'
  ...
```

One call per label took longer than the native JavaScript. The same 400,000 crossings with no
bytes to copy took under a tenth of the per-label time, so the cost is the encoding and copying,
not the call. The batch encodes the whole list once, crosses four times, decodes once, and came in
under the native time.

The batch is a second export, and a second function in each wrapper:

```js title="core.mjs"
/** Truncate many strings in one call: four crossings, one encode and one decode for the whole list. */
export function truncateAll(texts, max) {
  if (texts.length === 0) return []
  if (texts.some((text) => text.includes('\0'))) throw new Error('textcore: a string contains NUL, the batch separator')
  const input = encoder.encode(texts.join('\0'))
  const ptr = copyIn(input)
  const packed = wasm.truncate_batch(ptr, input.length, max)
  wasm.dealloc(ptr, input.length)
  boundary.crossings += 2
  return decoder.decode(copyOut(packed)).split('\0')
}
```

NUL is a safe separator because UTF-8 never uses a zero byte inside another character,[^4] so the
module can split on it without decoding first. The wrapper refuses any string that contains one
rather than let it split in two.

Run this measurement against your own function before you delete a port. A function that does
more work per byte than this one, such as a parser or a hash, pays for the copy. One that does
less makes every hot loop slower.

## Check it worked

Run the suite. Its seven tests cover the shared cases, agreement with the native port, the batch,
its separator, invalid UTF-8, memory, and pointers past 2 GiB:

```js title="core.test.mjs"
test('memory stops growing once every buffer is freed', () => {
  const label = 'Order 1: crème brûlée for table 7 ☕'
  for (let i = 0; i < 1_000; i++) truncate(label, 16)
  const settled = memoryBytes()
  for (let i = 0; i < 100_000; i++) truncate(label, 16)
  assert.equal(memoryBytes(), settled)
})
```

```bash
node --test core.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 394.116628
```

Read `# pass 7` and `# fail 0`. The memory test matters most in a long-running host, because
WebAssembly can [grow a memory](https://webassembly.github.io/spec/core/syntax/instructions.html)
but has no instruction that shrinks one. A wrapper that misses one `dealloc` per call raises the
high-water mark for the life of the process.

## When it goes wrong

Non-ASCII results lose their last characters, or fail as invalid UTF-8. The wrapper passed
`text.length`, a count of UTF-16 units, where the module expects bytes. Pass the length of the
encoded `Uint8Array`.

A write throws a `TypeError` about a detached `ArrayBuffer`. The wrapper made a view once and kept
it after `alloc` grew memory. Take a fresh view after any call that can allocate.

A call fails with `RuntimeError: unreachable` and no message. A Rust panic aborted the module: the
target [compiles with `-Cpanic=abort`](https://doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html)
by default, and `println!` does nothing there, so the message has nowhere to go. Validate inside
the core and return an error value, as `truncate_utf8` does for bad UTF-8. Discard an instance
after a trap, because it stopped mid-call without unwinding whatever it was changing.

Each one, run against the real module:

```bash
node pitfall.mjs
```

```text output
1. text.length passed where the module expects a byte count
  "Crème brûlée"  text.length 12  -> "Crème brûl"
  "Crème brûlée"  byte count  15  -> "Crème brûlée"
  "café"          text.length 4   -> error, not valid UTF-8
  "café"          byte count  5   -> "café"
2. a memory view made once, then an allocation that grows memory
  cached view length after a 4 MiB alloc: 0
  TypeError: Cannot perform %TypedArray%.prototype.set on a detached ArrayBuffer
3. a panic inside the module, here an allocation it cannot satisfy
  RuntimeError: unreachable
```

The first result is the dangerous one. `Crème brûl` is a valid string, so nothing downstream
notices that two characters went missing.

## When not to do this

Do not move a small utility to WebAssembly to make it faster. The benchmark here shows one call
per string taking longer than the JavaScript it replaced. Only a batch API won the time back, and
adopting it changes every call site.

Do not take this route when most of your contributors cannot build or debug the core's language.
A Python maintainer can step through a Python port, but the module reaches them as a binary from a
toolchain they may never have installed, and its panics reach them as `unreachable`.

Do not pass rich objects across the boundary on every call. Serializing them for each call puts an
encoder and a parser on both sides of the hot path.

The approach fits a core with a small, stable API and enough work per call to pay for the copy: a
parser, a validator, a formatter. For a few string helpers whose idiomatic ports already agree, a
shared corpus that keeps them agreeing costs less than a runtime.

## Related how-tos

- [Design the case shape of a language-neutral test corpus](/howto/design-the-case-shape-of-a-test-corpus)

- [Package one utility for Node, Deno, Bun, and browsers](/howto/package-a-utility-for-node-deno-and-browsers)

- [Choose between porting a library and reimplementing it](/howto/choose-between-a-port-and-a-reimplementation)

## Last verified

Verified 2026-10-21 against Rust 1.97.0, Node 22.22.0, Python 3.13.16, Go 1.24.7, `wasmtime`
49.0.0, and `wazero` 1.11.0. Every output block is what the command preceding it printed. The
timings come from one run on a shared machine, so compare the rows with each other rather than with
your own milliseconds. Extism, wasm-bindgen, PyO3, napi-rs, UniFFI, and `cgo` were read from their
documentation, not run.

[^1]: The isolation runs one way. The
[core specification](https://webassembly.github.io/spec/core/intro/introduction.html) says in a
footnote that no program can break WebAssembly's memory model. The same footnote adds that it
cannot stop an unsafe language from corrupting its own layout inside linear memory. A faulty core
cannot reach the host's memory, but it can still corrupt the module's own data, which is where
every result it returns is read from.

[^2]: The [specification](https://webassembly.github.io/spec/core/intro/introduction.html)
abbreviates the language to `Wasm` and gives the abbreviation a footnote of its own: a contraction
of WebAssembly, not an acronym, hence not all capitals. The tools on this page follow it in their
names, `wasmtime` and `wasm-bindgen` among them. The Rust target that built the module,
`wasm32-unknown-unknown`, follows it too, and adds two unknowns of its own.

[^3]: The [reference for `Memory.prototype.grow()`](https://developer.mozilla.org/en-US/docs/WebAssembly/Reference/JavaScript_interface/Memory/grow)
states the rule more strictly than a wrapper needs it: every call to grow detaches references to
the previous buffer, even `grow(0)`. A memory that grew by zero pages has, for every view made
before the call, still gone away. A shared memory is the exception, and its earlier buffer keeps
its length instead of updating.

[^4]: [RFC 3629](https://www.rfc-editor.org/rfc/rfc3629.html) lists the property second among
UTF-8's characteristics, after the one that makes plain ASCII valid UTF-8. A byte with an ASCII
value appears in a UTF-8 stream only as that ASCII character. The reason it gives is
compatibility with software such as the `printf()` function in C libraries, which parses on ASCII
values and passes the rest through. The RFC credits the design to Ken Thompson, guided by criteria from Rob Pike, in
September 1992, for the Plan 9 operating system.