Your truncate helper has a TypeScript original and ports in Python and Go, and the three
disagree on a string that starts with an emoji. TypeScript slices UTF-16 units and cuts the emoji
in half, Python keeps it, and Go slices bytes and returns invalid UTF-8. Each fix has to be
written three times, and the ports drift between releases.
What you get
You will end up with one Rust core compiled to WebAssembly, called from Node, Python, and Go through a thin wrapper each, and a measurement of what the boundary costs. This is for you if your ports of one small library keep drifting apart.
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 | 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 | 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 | 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 | 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 | 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 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.
/// 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:
cargo build --quiet --release --target wasm32-unknown-unknown && node inspect.mjs
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
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:
// 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
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:
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, 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:
node cases.mjs && PYTHONPATH=pylib python3 cases.py && go run .
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:
node --test bench.test.mjs
# 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:
/** 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:
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)
})
node --test core.test.mjs
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
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
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:
node pitfall.mjs
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
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.
Footnotes
-
The isolation runs one way. The core specification 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. ↩︎ Back to text
-
The specification abbreviates the language to
Wasmand 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,wasmtimeandwasm-bindgenamong them. The Rust target that built the module,wasm32-unknown-unknown, follows it too, and adds two unknowns of its own. ↩︎ Back to text -
The reference for
Memory.prototype.grow()states the rule more strictly than a wrapper needs it: every call to grow detaches references to the previous buffer, evengrow(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. ↩︎ Back to text -
RFC 3629 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. ↩︎ Back to text