Passing Maps and Sets Across the Boundary
This page answers one task: a WebAssembly module needs key-value data or unique collections from JavaScript — a lookup table, a set of selected IDs, a dictionary of settings — and results come back as empty objects, with keys converted to strings, or in a different order. You want conversions that preserve keys, order where it matters, and performance for large collections.
Prerequisites
- [ ] A Rust module using wasm-bindgen, and optionally
serde-wasm-bindgen. - [ ] Knowledge of the key types involved (strings, integers, composite keys).
- [ ] An idea of collection sizes — dozens of entries or millions.
Why Map and Set need care
JavaScript has two key-value structures: plain objects, whose keys are always strings (or symbols), and Map, whose keys can be any value and whose
iteration order is insertion order. Rust’s HashMap has arbitrary iteration order; BTreeMap iterates sorted by key; neither knows about JavaScript’s
distinction between objects and Maps. When converting, three decisions must be made: which JavaScript structure to use, how keys are represented, and
whether order is preserved.
serde-wasm-bindgen, the usual converter, serialises Rust maps to JavaScript Map objects by default — not plain objects — which surprises code that
expects result.someKey to work. It can be configured to produce plain objects instead (serialize_maps_as_objects(true)), which only works for
string-like keys. Sets serialise as arrays unless handled specially. Knowing these defaults avoids most confusion.
Step 1 — convert maps with serde-wasm-bindgen
use std::collections::{BTreeMap, HashMap};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn word_counts(text: &str) -> Result<JsValue, JsError> {
let mut counts: BTreeMap<&str, u32> = BTreeMap::new();
for w in text.split_whitespace() { *counts.entry(w).or_default() += 1; }
Ok(serde_wasm_bindgen::to_value(&counts)?) // a JS Map, sorted by key
}
#[wasm_bindgen]
pub fn apply_prices(prices: JsValue) -> Result<f64, JsError> {
let prices: HashMap<String, f64> = serde_wasm_bindgen::from_value(prices)?; // accepts Map or object
Ok(prices.values().sum())
}
from_value accepts both a JavaScript Map and a plain object for a Rust map type. to_value produces a Map unless configured otherwise:
let ser = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
Ok(counts.serialize(&ser)?) // a plain object
Step 2 — choose keys that survive conversion
String keys are straightforward. Integer keys convert to numbers in a Map (good) or to strings in a plain object (lossy). Composite keys — tuples,
structs — become arrays or objects as Map keys, and JavaScript Map compares object keys by identity, so a key built in Rust and returned cannot be
looked up with a freshly created equal array in JavaScript. Use composite keys only inside the module, and expose string or numeric keys at the boundary
— for example "x:y" strings or a numeric index.
64-bit integer keys (u64) become BigInt in JavaScript, and Map treats 1n and 1 as different keys; be consistent about which the JavaScript side
uses.
Step 3 — decide whether order matters
HashMap iteration order is arbitrary and differs between runs if the hasher is randomly seeded; returning one to JavaScript produces a Map in arbitrary
order, which shows up as UI lists that reshuffle. Use BTreeMap for sorted output or indexmap::IndexMap to preserve insertion order, matching
JavaScript’s Map semantics. Going the other way, a JavaScript Map converted into a HashMap loses its insertion order; convert into an IndexMap if
order carries meaning.
Step 4 — pass sets
A Rust HashSet or BTreeSet serialises as an array by default. For JavaScript callers that want a Set, wrap the array: new Set(result). Going in,
serde_wasm_bindgen::from_value can deserialise a JavaScript array into a set; a JavaScript Set must usually be spread into an array first
([...selected]), because serde’s sequence handling expects array-like input. For sets of integers — selected row IDs, enabled feature flags — a typed
array is cheaper still:
#[wasm_bindgen]
pub fn select_rows(ids: &[u32]) -> u32 {
let set: std::collections::HashSet<u32> = ids.iter().copied().collect();
count_visible(&set)
}
select_rows(Uint32Array.from(selectedIds));
Step 5 — encode large collections as flat arrays
Converting a Map with 100,000 entries through serde walks every entry and creates JavaScript objects for each, which costs milliseconds. For large maps
with simple types, flatten them into parallel typed arrays — keys in one, values in another — and rebuild the map inside the module:
const keys = new Uint32Array(map.size), values = new Float64Array(map.size);
let i = 0;
for (const [k, v] of map) { keys[i] = k; values[i] = v; i++; }
wasm.load_prices(keys, values);
#[wasm_bindgen]
pub fn load_prices(keys: &[u32], values: &[f64]) {
let map: HashMap<u32, f64> = keys.iter().copied().zip(values.iter().copied()).collect();
STATE.with(|s| s.borrow_mut().prices = map);
}
Two typed-array copies replace 100,000 individual conversions. For string keys, encode them as one UTF-8 blob plus an offsets array, as described in encoding strings across the Wasm boundary.
Keeping collections on one side
The cheapest conversion is none. If JavaScript only queries a large map occasionally, keep it inside the module and export lookup functions (get_price(id))
instead of converting the whole map back. If the module only needs a few entries from a large JavaScript map, pass those entries. Converting entire
collections each call is the most common reason Map-heavy interop code is slow.
Using js_sys::Map directly
js_sys::Map lets Rust code work with a JavaScript Map in place — get, set, for_each — without converting. Each operation is a call across the
boundary, so it suits small maps or sparse access, not iteration over large ones. It is also how Rust code can return a Map whose keys are JavaScript
objects (for example DOM nodes) that have no Rust equivalent.
Maps of structured values
Real maps rarely hold plain numbers: a map from product ID to a product record, from user ID to a list of permissions. Conversion cost then scales with the size of the values, not just the number of entries, because every nested object and string is converted too. Three patterns keep this manageable. Keep the records in one array and pass a map from key to index in that array, so values are converted once and the map itself is cheap. Use a struct-of-arrays layout for numeric fields — one typed array per field — so a million-row table crosses as a handful of copies. Or keep the records inside the module entirely and expose accessor functions for the fields JavaScript actually reads. Measure with realistic record sizes: a conversion that takes 2 ms for 10,000 entries with numeric values can take 60 ms when each value carries several strings.
Updating maps incrementally
When a map lives on both sides — the UI holds a copy for display, the module holds the authoritative version — reconverting the whole map after every change wastes work. Send changes instead: an array of inserted or updated entries and an array of removed keys, applied on the other side. The module can return the same kind of delta from operations that modify the map, so the UI’s copy stays in sync without full conversions. This is the same idea as a diff-based state update in UI frameworks, applied to the Wasm boundary, and it turns per-operation cost from proportional to the map’s size into proportional to the change.
Sets of strings
Sets of strings — tags, enabled feature names, selected file paths — are common and awkward: each string must be encoded to UTF-8 on the way in and decoded on the way out. For membership checks against a large, rarely changing set, build the set once inside the module and pass only the queried string per call; for small sets that change often, pass an array of strings and accept the conversion cost, which is small at that size.
Expected output
word_counts returns a Map sorted by word; apply_prices accepts either a Map or a plain object; settings are returned as a plain object via
serialize_maps_as_objects; selected IDs cross as a Uint32Array; a 100,000-entry price table loads through two typed arrays in 3 ms instead of 41 ms;
and UI lists no longer reshuffle between runs.
Gotchas
- Expecting plain objects from serde-wasm-bindgen. It returns
Mapby default. Configure or adapt. - Integer keys in plain objects. They become strings. Use
Mapor string keys deliberately. - Composite keys in JavaScript Maps. Compared by identity. Use string or numeric keys at the boundary.
- HashMap order in UI output. Arbitrary. Use
BTreeMaporIndexMap. - Converting huge maps per call. Flatten or keep data on one side.
Performance note
For a 100,000-entry u32 → f64 map, conversion through serde-wasm-bindgen took 41 ms; passing two typed arrays and rebuilding the map in Rust took
3 ms.
Frequently Asked Questions
Can wasm-bindgen accept HashMap parameters directly?
Not without serde or a typed-array encoding; use JsValue plus from_value.
Does JSON work for maps?
Only with string keys, and JSON.stringify turns a Map into {} unless converted first.
How do I return a JavaScript Set?
Return an array and wrap it in new Set() in the wrapper.
Is IndexMap slower than HashMap?
Slightly for removals; similar for lookups and inserts.
How do I keep a map in sync on both sides without reconverting it? Send deltas — upserted entries and removed keys — and apply them on the other side.
Related
- Serializing data with serde-wasm-bindgen — the converter.
- Passing arrays between JavaScript and Wasm — typed-array encodings.
- Passing nested objects efficiently — larger structures.
- Choosing between JSON and binary serialization — format choices.