Returning Large Structs from Wasm Functions
This page answers one question: a Rust or C function returns a struct, but the compiled WebAssembly function returns nothing — and takes an extra pointer parameter you never wrote. Or an exported function returns two values, and JavaScript receives an array. You want to understand how compilers return composite values in Wasm, what it costs, and how to design exports that return structured results.
Prerequisites
- [ ] Basic WAT reading skills (
wasm-tools printorwasm2wat). - [ ] A Rust or C function returning a struct or tuple.
- [ ] An idea of how the module is called (internally, or from JavaScript).
Wasm functions return values, not memory
WebAssembly functions return zero or more values of the core types (i32, i64, f32, f64, v128, references). The MVP allowed at most one result; the
multi-value proposal (now standard) allows several. A struct is not a value type, so a compiler must map it onto something Wasm can express. There are two
strategies.
Return slot (often called sret): the caller reserves space for the struct — usually on the shadow stack in linear memory — and passes a pointer as a hidden
first parameter; the callee writes the fields there and returns nothing. This works for any size and is how C ABIs have long handled large structs.
Multi-value: the callee returns the struct’s fields as several result values directly, without touching memory. That avoids memory traffic for small aggregates — a pair of floats, a pointer and a length — but becomes unwieldy for large structs.
Step 1 — recognise the return slot in WAT
#[repr(C)]
pub struct Stats { pub min: f64, pub max: f64, pub mean: f64, pub count: u32 }
#[no_mangle]
pub extern "C" fn stats(ptr: *const f64, len: usize) -> Stats { /* ... */ }
(func $stats (param $ret i32) (param $ptr i32) (param $len i32) ;; no result: writes into $ret
...
(f64.store offset=0 (local.get $ret) (local.get $min))
(f64.store offset=8 (local.get $ret) (local.get $max))
(f64.store offset=16 (local.get $ret) (local.get $mean))
(i32.store offset=24 (local.get $ret) (local.get $count)))
The first parameter is the return slot. Callers within the module allocate it on the shadow stack (adjusting __stack_pointer) and read the fields afterwards.
Step 2 — recognise multi-value returns
Small aggregates can use multi-value when the ABI and compiler allow it. In LLVM’s current default C ABI for wasm32, structs are typically returned indirectly
(through a slot) regardless of size, while a “multivalue” ABI variant returns small structs as multiple values; Rust’s extern "C" follows the C ABI. Internal
(non-exported) Rust functions are not bound by the C ABI, and the optimiser frequently inlines them, so the question only matters at ABI boundaries. In WAT,
multi-value looks like this:
(func $minmax (param $ptr i32) (param $len i32) (result f64 f64)
...
(local.get $min) (local.get $max))
Check what your toolchain emits rather than assuming; flags and defaults have changed over time.
Step 3 — understand the cost
The return slot costs a store per field in the callee and a load per field used by the caller, plus stack-pointer adjustments — cheap, but measurable in tight
loops. Engines keep the shadow stack in cache, so it rarely matters outside hot paths. Multi-value avoids the memory traffic entirely. When a small struct is
returned in a hot loop and profiling shows the cost, options include making the function #[inline] so the struct never materialises, splitting it into
separate scalar-returning functions, or (where the toolchain supports it) using a multi-value ABI.
Step 4 — return structs to JavaScript
For exported functions called from JavaScript, the raw ABI is inconvenient: JavaScript must allocate the return slot in linear memory and read fields with typed
arrays or DataView. With multi-value, a JavaScript call to an export with several results returns an array (const [min, max] = exports.minmax(p, n)), which is
pleasant for two or three numbers. For richer results, wasm-bindgen can return a struct as a JavaScript class instance (fields read through getters) or as a
plain object via serde; Emscripten’s Embind offers value_object. Choose by call frequency: plain objects and arrays for occasional calls, preallocated result
buffers read through typed arrays for calls made thousands of times per second.
// raw ABI: caller-allocated result slot
const ret = exports.malloc(32);
exports.stats(ret, ptr, len);
const dv = new DataView(exports.memory.buffer, ret, 32);
const result = { min: dv.getFloat64(0, true), max: dv.getFloat64(8, true), mean: dv.getFloat64(16, true), count: dv.getUint32(24, true) };
exports.free(ret);
Step 5 — design exports deliberately
For exports, prefer interfaces that are easy to call from JavaScript: a few scalar results via multi-value, a pointer to a stable result buffer owned by the module, or a binding generator’s types. Avoid forcing JavaScript to allocate and free return slots on every call — that is error-prone and adds two extra calls.
Passing structs as arguments
The same choices apply in the other direction. Small structs passed by value may be split into scalar parameters (an i32 per field) or, more commonly in the
C ABI for wasm32, passed indirectly: the caller copies the struct to memory and passes a pointer. Large structs are always passed by pointer. When reading WAT,
a function whose parameter list does not match the source signature usually has struct arguments lowered this way — a single i32 where the source had a
Point { x: f64, y: f64 }. For exports called from JavaScript, that means JavaScript must lay out the struct in memory before calling, which is another reason
to give exported functions scalar parameters or to rely on a binding generator that hides the layout.
Mixing toolchains and ABIs
Problems appear when objects compiled with different ABI assumptions are linked together — for example a C library compiled with a multi-value ABI variant and Rust code using the default C ABI. The signatures of functions returning structs then disagree: one side expects a hidden return-slot parameter, the other returns multiple values. Linking may fail with signature mismatch errors, or, through indirect calls, trap at runtime with “indirect call signature mismatch”. Build all objects that call each other with the same ABI settings, and treat ABI-related flags as part of the toolchain configuration pinned for the whole project, not as per-library choices.
The Component Model’s canonical ABI
Components avoid these questions at their boundaries: the canonical ABI defines how records and other compound WIT types are passed — flattening small values into parameters and results, and spilling larger ones to memory with a defined layout — so producers and consumers agree regardless of language. Inside a component, the language’s own ABI still applies.
Expected output
You can identify a hidden return-slot parameter in WAT, explain why the function has no result, recognise multi-value returns, estimate the cost of each, and choose an export design — multi-value for small numeric results, a module-owned result buffer for hot calls, generated bindings for rich objects.
Gotchas
- Calling a slot-returning export without the slot. Arguments shift by one. Pass the pointer first.
- Reading result fields after memory growth with old views. Views detach. Create them after the call.
- Assuming toolchain ABI defaults. They change. Inspect the WAT.
- Allocating return slots per call from JavaScript. Slow and leak-prone. Use a module-owned buffer.
- Large structs via multi-value. Unwieldy and not what ABIs choose. Use slots or buffers.
- Linking objects built with different ABIs. Struct-returning signatures disagree. Use one ABI setting everywhere.
Performance note
In a loop calling a small min/max function 10 million times, returning through a slot took about 1.3× as long as a multi-value return when not inlined; with inlining, both disappeared into the loop.
Frequently Asked Questions
Do all engines support multi-value? Yes, current engines do; it has been standard for years.
Why does C still use return slots for small structs? ABI stability and compatibility; changing the default ABI would break linking with existing objects.
Can JavaScript call a multi-value export? Yes — it returns an array of the values.
Does the return slot live on the Wasm value stack? No — it lives in linear memory, on the shadow stack or wherever the caller allocated it.
Why does my exported function take one i32 where I passed a struct? The C ABI passes structs by pointer; the caller must place the struct in linear memory and pass its address.
Do components have the same problem? No — the canonical ABI defines how records cross component boundaries, independent of language.
What does a signature mismatch trap have to do with structs? Objects disagreeing on struct return ABI produce different function types, which trap when called indirectly.
Related
- Returning multiple values from Wasm functions — multi-value.
- Understanding the shadow stack in linear memory — where slots live.
- Returning structs from Wasm to JavaScript — binding-level options.
- Reading a compiled function’s frame in WAT — stack frames.
← Back to Stack vs Heap Execution Model