Serde Serialization for Wasm
Why Serde Matters in Wasm
When Rust/Wasm communicates with JavaScript, data must cross the Wasm boundary. Rust structs live in linear memory; JavaScript objects live on the JS heap. Serde bridges this gap by converting between the two representations.
Rust/Wasm side JS side
┌────────────────┐ ┌────────────────┐
│ struct User { │ │ { name: "Ali", │
│ name: String │ serde │ age: 30, │
│ age: u32 │ <======> │ role: "admin"}│
│ role: Role │ │ │
│ } │ │ (JS Object) │
└────────────────┘ └────────────────┘
Linear Memory JS HeapTwo Approaches: serde-wasm-bindgen vs serde_json
Approach 1: serde-wasm-bindgen (Recommended)
Converts directly between Rust types and JsValue without going through a JSON string:
use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
struct Point { x: f64, y: f64 }
#[wasm_bindgen]
pub fn process_point(val: JsValue) -> Result<JsValue, JsValue> {
let point: Point = serde_wasm_bindgen::from_value(val)?;
let result = Point { x: point.x * 2.0, y: point.y * 2.0 };
Ok(serde_wasm_bindgen::to_value(&result)?)
}Approach 2: serde_json (via JsValue string)
Serializes to a JSON string, then parses on the other side:
#[wasm_bindgen]
pub fn process_point_json(json: &str) -> String {
let point: Point = serde_json::from_str(json).unwrap();
let result = Point { x: point.x * 2.0, y: point.y * 2.0 };
serde_json::to_string(&result).unwrap()
}Comparison
serde-wasm-bindgen path:
Rust struct ──> JsValue (direct, one step)
serde_json path:
Rust struct ──> JSON String ──> JS JSON.parse() ──> JS Object
^ ^
serialize parse
(Rust) (JS engine)| Feature | serde-wasm-bindgen | serde_json |
|---|---|---|
| Intermediate format | None (direct) | JSON string |
| Supports Map, Set, Date | Yes (native JS types) | No (JSON subset only) |
| Supports BigInt | Yes | No |
| Supports undefined vs null | Yes | No (both become null) |
| Binary size overhead | ~3 KB | ~15-25 KB |
| Speed (small objects) | Fast | Similar |
| Speed (large arrays) | Faster | Slower (string alloc) |
| Debugging ease | Harder (opaque) | Easy (readable JSON) |
Deriving Serialize and Deserialize
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct GameState {
score: u64,
level: u32,
player: Player,
inventory: Vec<Item>,
settings: HashMap<String, String>,
}
#[derive(Serialize, Deserialize, Debug)]
struct Player {
name: String,
position: (f64, f64),
health: f32,
}
#[derive(Serialize, Deserialize, Debug)]
struct Item {
id: u32,
name: String,
quantity: u32,
}This single #[derive] generates all the code needed to convert to/from any serde-supported format.
Enum Serialization Strategies
Serde supports multiple enum representations via attributes:
// Default: externally tagged
#[derive(Serialize)]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"Circle": {"radius": 5.0}}
// Internally tagged
#[derive(Serialize)]
#[serde(tag = "type")]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"type": "Circle", "radius": 5.0}
// Adjacently tagged
#[derive(Serialize)]
#[serde(tag = "type", content = "data")]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"type": "Circle", "data": {"radius": 5.0}}
// Untagged
#[derive(Serialize)]
#[serde(untagged)]
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
// -> {"radius": 5.0} (no tag — inferred on deserialize)Comparison Table
| Strategy | JSON Output | JS-Friendly? | Disambiguates? |
|---|---|---|---|
| External (default) | {"Variant": {...}} |
Awkward | Yes |
Internal tag |
{"type": "Variant", ...} |
Natural | Yes |
| Adjacent | {"type": "Variant", "data": {...}} |
Clear | Yes |
| Untagged | {...} (fields only) |
Cleanest | Not always |
For JavaScript interop, internal tagging (
#[serde(tag = "type")]) is usually the best choice — it matches TypeScript discriminated unions.
Handling Option and Result
#[derive(Serialize, Deserialize)]
struct UserProfile {
name: String,
bio: Option<String>, // null or missing in JSON
#[serde(default)]
verified: bool, // defaults to false if missing
#[serde(skip_serializing_if = "Option::is_none")]
avatar_url: Option<String>, // omitted entirely if None
} Serialization behavior:
Field Value JSON Output
─────────────────────────────────────────────────
bio Some("Hi") "bio": "Hi"
bio None "bio": null
avatar_url Some("url") "avatar_url": "url"
avatar_url None (field omitted)
verified (missing) - defaults to falseCommon Serde Attributes
| Attribute | Effect |
|---|---|
#[serde(rename = "camelCase")] |
Rename a single field |
#[serde(rename_all = "camelCase")] |
Rename all fields (on struct/enum) |
#[serde(default)] |
Use Default::default() if field is missing |
#[serde(skip)] |
Don't serialize or deserialize this field |
#[serde(skip_serializing_if = "...")] |
Conditionally skip during serialization |
#[serde(flatten)] |
Inline a nested struct's fields |
#[serde(with = "module")] |
Custom (de)serialization for a field |
#[serde(deny_unknown_fields)] |
Error on unexpected JSON keys |
Nested and Complex Types
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Dashboard {
user_name: String, // -> "userName"
widgets: Vec<Widget>,
layout: HashMap<String, Position>,
#[serde(flatten)]
metadata: Metadata, // fields inlined
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind")]
enum Widget {
Chart { data_source: String, chart_type: String },
Table { columns: Vec<String>, row_count: u32 },
Text { content: String },
}
#[derive(Serialize, Deserialize)]
struct Position { x: i32, y: i32, w: u32, h: u32 }
#[derive(Serialize, Deserialize)]
struct Metadata {
created_at: String,
version: u32,
}Resulting JSON:
{
"userName": "alice",
"widgets": [
{"kind": "Chart", "data_source": "api/sales", "chart_type": "bar"},
{"kind": "Text", "content": "Welcome!"}
],
"layout": {
"widget-0": {"x": 0, "y": 0, "w": 6, "h": 4}
},
"created_at": "2025-01-01",
"version": 3
}Performance: Minimizing Serialization Cost
1. Avoid Unnecessary Copies
// BAD — serializes entire game state every frame
fn update(state: &GameState) -> JsValue {
serde_wasm_bindgen::to_value(state).unwrap()
}
// BETTER — only serialize what changed
#[derive(Serialize)]
struct StateDelta {
updated_entities: Vec<EntityUpdate>,
removed_ids: Vec<u32>,
}2. Use Binary Formats for Large Data
// JSON: human-readable but large
let json = serde_json::to_vec(&data)?; // ~500 bytes
// bincode: compact binary format
let bin = bincode::serialize(&data)?; // ~120 bytes
// Transfer as Uint8Array through data channel or postMessage3. Pre-allocate Buffers
// BAD — allocates a new String each call
fn serialize(data: &Data) -> String {
serde_json::to_string(data).unwrap()
}
// BETTER — reuse a buffer
fn serialize_into(data: &Data, buf: &mut Vec<u8>) {
buf.clear();
serde_json::to_writer(buf, data).unwrap();
}Format Comparison for Wasm
| Format | Human Readable | Size | Speed | Wasm Binary Overhead |
|---|---|---|---|---|
| serde_json | Yes | Large | Medium | ~20 KB |
| serde-wasm-bindgen | N/A (direct) | N/A | Fast | ~3 KB |
| bincode | No | Small | Fast | ~5 KB |
| postcard | No | Smallest | Fastest | ~3 KB |
| rmp (msgpack) | No | Small | Fast | ~8 KB |
Error Handling
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn parse_config(val: JsValue) -> Result<JsValue, JsValue> {
// from_value returns a clear error if the shape doesn't match
let config: Config = serde_wasm_bindgen::from_value(val)
.map_err(|e| JsValue::from_str(&format!("Invalid config: {}", e)))?;
// Process...
let result = process(config);
serde_wasm_bindgen::to_value(&result)
.map_err(|e| JsValue::from_str(&format!("Serialization failed: {}", e)))
}Summary
For Wasm projects, serde-wasm-bindgen is the recommended default — it converts directly between Rust types and JavaScript values with minimal overhead. Use serde_json when you need human-readable output or are passing data as strings (e.g., localStorage, HTTP bodies). Use #[serde(tag = "type")] for enums and #[serde(rename_all = "camelCase")] for JS-friendly field names. For large data transfers, consider binary formats like bincode or postcard.