wasm-transfer is a Rust and WebAssembly study project about moving complex structured data from Rust into JavaScript efficiently.
Passing small values across the WASM boundary is straightforward. Passing many complex records is different: each boundary crossing, allocation, string conversion, object construction, and copy starts to matter.
This repository explores that boundary with a realistic nested structure:
Orderis exported to JavaScript withwasm-bindgen.Orderowns a privateVec<Product>.Productcontains aString,f32,u32, andbool.- JavaScript can read products either as native JS objects or through binary views.
The goal is to understand when a simple JS object API is good enough and when a binary representation is worth the extra wrapper code.
Install Rust using rustup (the official installer):
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shAfter installation, restart your terminal or run:
source $HOME/.cargo/envVerify installation:
rustc --version
cargo --versionThis project uses wasm-pack to compile the Rust crate to WebAssembly and generate the JavaScript bindings in pkg/.
Install with the official installer:
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | shOr with Cargo:
cargo install wasm-packVerify installation:
wasm-pack --versionwasm-transfer/
├── src/ # Rust WASM crate
│ ├── product.rs
│ ├── order.rs
│ └── serialize/ # one module per transfer strategy
├── js/
│ ├── types/ # shared TypeScript types
│ ├── views/ # binary product decoders
│ ├── scripts/ # demo and benchmark
│ └── tests/ # Node integration tests
├── tests/web.rs # wasm32 browser tests
└── pkg/ # wasm-pack output (generated)
Each strategy crosses the WASM boundary differently. The diagram below shows the path from Rust to what JavaScript receives.
flowchart TB
subgraph rust ["Rust (Order)"]
O["Vec<Product>"]
end
subgraph js_objects ["JavaScript objects"]
J1["Array of plain objects"]
end
subgraph js_binary ["JavaScript binary"]
J2["Uint8Array + custom view"]
J3["[ptr, len] + raw view"]
J4["Uint8Array + msgpackr decode"]
J5["Uint8Array (Rust archive)"]
end
O -->|"gloo-utils serde"| J1
O -->|"serde_wasm_bindgen"| J1
O -->|"encoded raw bytes"| J2
O -->|"raw struct memory"| J3
O -->|"MessagePack"| J4
O -->|"rkyv"| J5
Ratings are relative within this project (not universal benchmarks). Simple = little JS glue code. Fast = large vectors. Portable = meaningful to JavaScript without Rust layout knowledge.
| Method | Output | Simple | Fast | Portable |
|---|---|---|---|---|
gloo-utils serde |
JS objects | ●●●●● | ●●○○○ | ●●●●● |
serde_wasm_bindgen |
JS objects | ●●●●○ | ●●●○○ | ●●●●● |
| Encoded raw bytes | Uint8Array + view |
●●○○○ | ●●●●○ | ●●○○○ |
| Raw struct memory | [ptr, len] + view |
●○○○○ | ●●●●● | ●○○○○ |
| MessagePack | Uint8Array + decode |
●●●○○ | ●●●●○ | ●●●●○ |
rkyv |
Uint8Array |
●○○○○ | ●●●●● | ○○○○○ |
Best when dataset size is small or developer ergonomics matter more than throughput.
gloo-utils serde — serialize_items_with_serde → getItemsJson()
- Returns a normal JS array of
{ sku, price, quantity, in_stock }. - Tradeoff: serializes through JSON internally; simple API, expensive for large data.
serde_wasm_bindgen — serialize_items_with_serde_wasm_bindgen → getItemsJs()
- Returns native JS values without a JSON string step.
- Tradeoff: still builds many JS objects field-by-field for large vectors.
Best when moving thousands of records and you want fewer allocations at the boundary.
Encoded raw bytes — serialize_items_with_bytes → getItemsBinary() + ProductsView
- Compact
Uint8Arraywith a documented layout; strings still point into WASM memory. - Tradeoff: custom layout and lifetime rules on the JS side.
Raw struct memory — serialize_items_with_raw_bytes → getItemsBinaryRaw() + ProductsViewRaw
- Hands off
[pointer, length]directly into the RustVecbacking store. - Tradeoff: fastest path, but unsafe if Rust memory layout or lifetimes change.
MessagePack — serialize_items_with_rmp_serde → getItemsBinaryMessagePack() + msgpackr
- Portable binary blob; decode on the JS side with
msgpackr. - Tradeoff: extra decode step, but good balance of speed and interoperability.
rkyv — serialize_items_with_rkyv → Uint8Array
- Zero-copy archive bytes optimized for Rust consumption.
- Tradeoff: not a practical JS interchange format; bytes are not meaningful to JavaScript alone.
Build the Node-targeted WASM package:
npm run buildRun the demo:
npm run demoRun the benchmark:
npm run demo:benchmarkMinimal TypeScript example:
import { unpack } from "msgpackr";
import { Order } from "./pkg/wasm_transfer.js";
import { ProductsView } from "./js/views/products.js";
const order = new Order(1, "Ada");
order.addProduct("SKU001", 29.99, 2, true);
order.addProduct("SKU002", 10.0, 1, false);
const jsItems = order.getItemsJs();
const msgpackItems = unpack(order.getItemsBinaryMessagePack());
const binaryItems = new ProductsView(order.getItemsBinary());
console.log(jsItems[0].sku);
console.log(msgpackItems[0][0]);
console.log(binaryItems.get(0).sku);
order.free();For small and medium datasets, returning native JavaScript objects through serde_wasm_bindgen or gloo-utils is the easiest and most maintainable approach.
For large vectors, binary transfer is the important optimization. A custom raw-byte view gives maximum control and can be very fast, but it couples JavaScript to Rust memory layout and lifetime rules. MessagePack is the best practical compromise in this project: it keeps the WASM-to-JS transfer compact and fast while preserving a portable, JavaScript-decodable format.
rkyv is useful to demonstrate zero-copy archive ideas on the Rust side, but it is not a good Rust-to-JavaScript interchange format here because the bytes are not directly meaningful to JavaScript.