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.

Plain object versus Map at the boundary A plain object has string keys only, supports dot access and JSON, and iterates mostly in insertion order with integer-like keys first. A Map keeps keys of any type and strict insertion order, and serde-wasm-bindgen produces it by default for Rust maps. plain object keys become strings obj.key and JSON work integer keys sorted first string-keyed config Map any key type kept strict insertion order serde-wasm-bindgen default general key-value data

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.

Rust map types and the order JavaScript sees HashMap produces an arbitrary order that can change between runs. BTreeMap produces keys in sorted order. IndexMap preserves insertion order like a JavaScript Map. Choose the Rust type by what order the JavaScript side expects. Rust type order in the JS Map use when HashMap arbitrary, may vary order irrelevant BTreeMap sorted by key sorted display or diffing indexmap::IndexMap insertion order mirroring a JS Map

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 Map by default. Configure or adapt.
  • Integer keys in plain objects. They become strings. Use Map or 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 BTreeMap or IndexMap.
  • 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.

Loading a 100,000-entry map into Wasm Milliseconds to pass a JavaScript Map of 100,000 integer keys and float values into a Rust HashMap through serde-wasm-bindgen and through two parallel typed arrays. ms per load serde-wasm-bindgen from Map 41 ms two typed arrays 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.

← Back to Passing Complex Types Across the Boundary