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 print or wasm2wat).
  • [ ] 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.

Return slot versus multi-value returns With a return slot, the caller passes a pointer to space in linear memory and the callee writes the struct there, which works for any size but costs stores and loads. With multi-value, the callee returns fields directly as values, avoiding memory traffic for small structs but not suited to large ones. return slot (sret) hidden pointer parameter fields written to memory any size, C ABI default large structs multi-value returns fields returned as values no memory traffic small aggregates only small structs, pairs

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.

Calling a function that returns through a slot The caller lowers the stack pointer to reserve space for the struct, passes that address as the hidden first argument, and calls the function. The callee writes each field to memory through the pointer. After the call, the caller loads the fields it needs and restores the stack pointer. caller reserves space stack pointer − 32 pass hidden pointer first argument callee stores fields f64.store ×3, i32.store caller loads fields after return restore stack pointer + 32

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.

Time for 10 million calls returning two values Relative time for 10 million calls of a small function returning two f64 values through a return slot in linear memory, as multi-value results, and when inlined into the caller. relative time return slot 1.3 × multi-value 1 × inlined 0.4 ×

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.

← Back to Stack vs Heap Execution Model