Serializing Data with serde-wasm-bindgen
This page answers one task: a Rust WebAssembly module needs to exchange structured data — configuration objects, records, nested lists — with JavaScript as ordinary JavaScript objects, without writing a binding for every field and without round-tripping through JSON strings.
Prerequisites
- [ ] A Rust crate built with wasm-bindgen.
- [ ]
serdewith thederivefeature, andserde-wasm-bindgenas dependencies. - [ ] Types that implement or can derive
SerializeandDeserialize.
Three ways to move structured data
wasm-bindgen exports Rust structs as JavaScript classes whose fields live in linear memory, which is right for long-lived objects with methods, as in exporting Rust structs as JavaScript classes. For plain data — a config object passed in, a list of search results passed out — classes are awkward: callers must free them, and nested data needs a class at every level.
The usual alternative is JSON: serde_json::to_string in Rust, JSON.parse in JavaScript. It works and is fast, but every value is encoded to text and
parsed again, and some types are lost on the way (maps with non-string keys, u64 precision, undefined versus null, Uint8Array).
serde-wasm-bindgen takes a third path: it walks the Rust value with serde and builds the JavaScript object directly through wasm-bindgen calls —
Object for structs and maps, Array for sequences, numbers, strings, BigInt where configured — and walks JavaScript objects the same way in
reverse. No intermediate string exists, and the result is an ordinary object with nothing to free.
Step 1 — add the dependencies and derive
[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchOptions {
pub query: String,
pub max_results: u32,
#[serde(default)]
pub fuzzy: bool,
pub fields: Option<Vec<String>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Hit { pub id: u32, pub score: f32, pub title: String, pub snippet: String }
rename_all = "camelCase" makes Rust’s max_results appear as JavaScript’s maxResults, matching each language’s conventions.
Step 2 — accept and return JsValue
#[wasm_bindgen]
pub fn search(options: JsValue) -> Result<JsValue, JsValue> {
let opts: SearchOptions = serde_wasm_bindgen::from_value(options)?; // JS object → Rust struct
let hits: Vec<Hit> = run_search(&opts);
Ok(serde_wasm_bindgen::to_value(&hits)?) // Rust Vec → JS array of objects
}
const hits = search({ query: "memory grow", maxResults: 10, fuzzy: true });
// [{ id: 42, score: 0.91, title: "Growing Memory…", snippet: "…" }, …]
Invalid input — a missing query, maxResults as a string — makes from_value return an error describing the field, which becomes a thrown JavaScript
error. That gives argument validation for free, and the error message names the offending field and the type that was expected, which is usually all a
caller needs to fix the call.
Step 3 — choose the serializer settings
The default serializer makes conservative choices. Configure it for the types you use:
const SER: serde_wasm_bindgen::Serializer = serde_wasm_bindgen::Serializer::new()
.serialize_maps_as_objects(true) // HashMap<String, _> → {} instead of Map
.serialize_large_number_types_as_bigints(true); // u64/i64 → BigInt, no precision loss
let js = hits.serialize(&SER)?;
By default, Rust maps become JavaScript Map objects, which preserves non-string keys but surprises code that expects plain objects. u64 values
beyond 2⁵³ cannot be represented exactly as JavaScript numbers; serialising them as BigInt keeps them exact, at the cost of callers handling BigInt,
as discussed in
passing 64-bit integers with BigInt.
Vec<u8> and serde_bytes fields become Uint8Array, which JSON cannot represent at all.
Step 4 — know when JSON is faster
serde-wasm-bindgen makes one boundary call per value it creates — per object, per property, per string. For small and medium payloads that overhead is
lower than encoding and parsing text. For large payloads with thousands of small objects, the many boundary calls add up, and JSON.parse — one of the
most optimised functions in every JavaScript engine — can be faster: the module builds one string, crosses the boundary once, and the engine parses it
natively. Measure with your real data. A useful rule of thumb from benchmarks: below a few thousand values, serde-wasm-bindgen is faster and simpler;
for tens of thousands of small records, JSON often wins; for numeric bulk data, neither — pass typed arrays, as in
passing arrays between JavaScript and Wasm.
Step 5 — keep binary size in check
serde’s derive generates code per type, and serde-wasm-bindgen adds its serializer, typically 10–30 KB of Wasm for a moderate set of types after
wasm-opt. Generic code for many large types grows faster. If size matters, serialise only the types that actually cross the boundary, avoid deriving
Deserialize for output-only types, and compare against JSON, whose serde_json implementation is of similar size. Checking the effect in CI, as in
catching size regressions in CI,
keeps it visible.
Typing the JavaScript side
Functions that take and return JsValue appear in wasm-bindgen’s TypeScript output as any, which throws away the structure you defined in Rust. Two
approaches restore it. The lightweight one is a typescript_custom_section with hand-written interfaces and a thin typed wrapper in JavaScript that casts
the results. The automated one is the tsify crate: deriving Tsify alongside Serialize and Deserialize generates TypeScript interfaces from the
Rust types and lets the exported function signatures use them directly, so search(options: SearchOptions): Hit[] appears in the .d.ts file. Either
way, the TypeScript types then describe exactly what serde produces — including the camelCase renames and optional fields — and drift between the Rust
definition and the JavaScript expectation becomes a compile error rather than a runtime surprise. Generating the types is the more reliable option for
larger APIs, because the hand-written interfaces otherwise need updating every time a field changes. The customisation hooks are covered in
customising TypeScript output from wasm-bindgen.
Deserialising input defensively
Data arriving from JavaScript is untrusted in the sense that callers can pass anything, and serde makes it easy to be strict about it. Mark structs
with #[serde(deny_unknown_fields)] so that a misspelt option — maxResult instead of maxResults — fails loudly instead of being silently ignored and
replaced by a default. Use #[serde(default)] only for fields that genuinely have a sensible default, and validate ranges after deserialisation: serde
checks that max_results is a u32, not that it is below 1,000. Large inputs deserve a size check before conversion — a caller that passes an array of a
million objects will make from_value allocate for all of them inside linear memory. For enums that select behaviour, a #[serde(rename_all)]
attribute keeps the accepted string values aligned with what JavaScript callers write, and an unknown variant produces a clear error naming the valid
choices. The result is an exported function whose input contract is enforced in one place, with error messages that point callers at the field they
got wrong, instead of a trap or a confusing result deep inside the module.
Expected output
search({ query: "memory grow", maxResults: 10 }) returns an array of ten plain objects with camelCase fields; passing { maxResults: "ten" } throws
Error: invalid type: string "ten", expected u32; and no wrapper objects need freeing.
Gotchas
- Maps arriving as
Map. Code readingresult.keygetsundefined. Enableserialize_maps_as_objectsor usemap.get(key). nullversusundefined.Nonebecomesundefinedby default; JSON-based callers may expectnull. Configureserialize_missing_as_null.- Large numbers losing precision.
u64above 2⁵³ rounds silently. Opt intoBigInt. - Slow with huge arrays of small objects. One boundary call per value. Benchmark against JSON.
- Silently ignored typos in options. Unknown fields are dropped by default. Add
deny_unknown_fields. - Untyped
JsValuein TypeScript. Generate interfaces withtsifyor a custom section.
Performance note
Returning 500 search hits took 0.21 ms with serde-wasm-bindgen and 0.34 ms through serde_json plus JSON.parse in Chrome. For 50,000 small records the
picture reversed: 41 ms with serde-wasm-bindgen against 23 ms with JSON. Exported classes were fastest to return but slowest to read field by field.
Frequently Asked Questions
Is serde-wasm-bindgen the same as JsValue::from_serde?
No. The older from_serde method used JSON internally and is deprecated; serde-wasm-bindgen converts directly.
Can it handle enums?
Yes, with serde’s enum representations (tag, untagged, content); see
passing enums across the boundary.
Does it work with Date?
Not directly. Serialise timestamps as numbers or ISO strings and convert in JavaScript.
Can I deserialise a Map from JavaScript into a HashMap?
Yes. Both Map and plain objects deserialise into Rust maps.
Can I use #[serde(flatten)] and other attributes?
Yes. serde-wasm-bindgen honours serde’s attributes, so rename, flatten, skip and default work as they do with JSON.
Should I convert once at the boundary or keep JsValue around?
Convert once at the boundary into Rust types. Holding JsValue and reading properties from Rust later costs a boundary call per access.
Related
- Choosing between JSON and binary serialization — the wider comparison.
- Passing optional values and nulls — the
Nonemapping in depth. - Passing JS objects to Rust with wasm-bindgen — working with JsValue directly.
- Returning structs from Wasm to JavaScript — the class-based alternative.