JSON and WebAssembly: serde_json, wasm-bindgen, and JS Interop

Last updated:

WebAssembly (Wasm) modules cannot directly access JavaScript objects — to pass JSON between JS and Wasm, you either serialize to a string and pass the pointer, or use wasm-bindgen's JsValue and serde-wasm-bindgen to let serde handle the conversion automatically. The string-passing approach (JSON.stringify -> Wasm pointer -> JSON.parse) costs roughly 1 μs for a 1 KB payload due to UTF-8 encoding and a memory copy across the JS/Wasm boundary. serde-wasm-bindgen avoids the string round-trip by converting directly between V8 objects and Rust types — 3–5× faster for small payloads. This guide covers both approaches in Rust with wasm-pack, plus how to call Rust JSON processing functions from JavaScript and return typed results. Whether you need in-browser JSON schema validation, high-throughput data transformation, or cryptographic JSON signing, the patterns here apply directly to production Wasm modules.

How JSON crossing the JS/Wasm boundary works

WebAssembly modules run in an isolated linear memory — a contiguous byte array separate from the JavaScript heap. JavaScript objects, including JSON-parsed objects, live in V8's managed heap and cannot be passed to Wasm by reference. Every value crossing the boundary must be explicitly transferred.

The Wasm linear memory model exposes three primitives for data exchange: integers (i32, i64), floats (f32, f64), and memory pointers (also i32offsets into the module's memory buffer). Passing a JSON object requires one of two strategies:

  • String-passing: serialize the JS object to a JSON string with JSON.stringify(), copy the UTF-8 bytes into Wasm linear memory, pass a pointer and length to the Rust function, and parse with serde_json::from_str() or serde_json::from_slice().
  • JsValue interop: use wasm-bindgen's JsValue type, which is a reference-counted handle to a JS value. The JS object stays on the V8 heap; Rust holds a handle. serde-wasm-bindgen walks the JS object graph and deserializes it directly into a Rust type without serializing to JSON first.

wasm-bindgen bridges the two models: it generates JavaScript glue code that translates between JavaScript values and the primitive types Wasm exports. When a Rust function is annotated with #[wasm_bindgen] and accepts JsValue, wasm-bindgen emits JS glue that wraps the incoming JavaScript value in a JsValue handle and passes it to Rust by reference. The Rust code never needs to know about V8 internals — it just calls serde_wasm_bindgen::from_value() to deserialize the handle into a typed Rust struct.

Understanding which approach to use depends on payload size, call frequency, and whether you control the JavaScript caller. For quick prototypes or large payloads where encoding overhead is negligible, string-passing is simpler to set up. For high-frequency calls with small-to-medium JSON payloads, serde-wasm-bindgen is measurably faster and produces cleaner Rust code.

String-passing approach: JSON.stringify in JS, serde_json in Rust

The string-passing approach works without any wasm-bindgen type magic. The JavaScript caller serializes the object to a JSON string, wasm-bindgen copies the UTF-8 bytes into Wasm memory, and the Rust function receives a &str slice. This is the lowest-dependency path: you only need wasm-bindgen and serde_json.

Cargo.toml setup:

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Rust (src/lib.rs) — accept a JSON string, process, return JSON string:

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct User {
    id: u64,
    name: String,
    email: String,
    active: bool,
}

#[derive(Serialize)]
struct Summary {
    total: usize,
    active_count: usize,
    names: Vec<String>,
}

/// Accept a JSON string from JS, parse it, process in Rust, return JSON string.
#[wasm_bindgen]
pub fn summarize_users(json_str: &str) -> Result<String, JsValue> {
    let users: Vec<User> = serde_json::from_str(json_str)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    let summary = Summary {
        total: users.len(),
        active_count: users.iter().filter(|u| u.active).count(),
        names: users.into_iter().map(|u| u.name).collect(),
    };

    serde_json::to_string(&summary)
        .map_err(|e| JsValue::from_str(&e.to_string()))
}

JavaScript caller:

import init, { summarize_users } from './pkg/my_wasm.js';

await init(); // load and compile the .wasm file once

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com', active: true },
  { id: 2, name: 'Bob',   email: 'bob@example.com',   active: false },
];

// Serialize on the JS side, pass string, parse returned string
const resultJson = summarize_users(JSON.stringify(users));
const result = JSON.parse(resultJson);
console.log(result);
// { total: 2, active_count: 1, names: ['Alice', 'Bob'] }

The round-trip cost of JSON.stringify + UTF-8 copy into Wasm memory + serde_json::from_str + serde_json::to_string + JSON.parse adds roughly 1–3 μs for a 1 KB payload. For workloads that call this function fewer than ~10 000 times per second, the overhead is imperceptible. For tight loops, prefer the serde-wasm-bindgen approach in the next section.

serde-wasm-bindgen: JsValue deserialization without string overhead

serde-wasm-bindgen provides from_value::<T>(JsValue) and to_value(&T)that convert directly between JavaScript values and Rust types using serde's visitor pattern. No JSON string is ever produced: the library reads V8 object properties one by one and feeds them to serde's Deserializer interface, which drives your #[derive(Deserialize)] implementation.

Cargo.toml:

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"         # still useful for pure-Rust JSON logic
serde-wasm-bindgen = "0.6"

Rust — accept JsValue, return JsValue:

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
pub struct InputPayload {
    pub values: Vec<f64>,
    pub label: String,
}

#[derive(Serialize)]
pub struct OutputPayload {
    pub sum: f64,
    pub mean: f64,
    pub label: String,
}

/// No JSON string round-trip — JsValue goes in, JsValue comes out.
#[wasm_bindgen]
pub fn compute_stats(js_input: JsValue) -> Result<JsValue, JsValue> {
    // Deserialize from the JS object directly
    let input: InputPayload = serde_wasm_bindgen::from_value(js_input)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    let sum: f64 = input.values.iter().sum();
    let mean = if input.values.is_empty() {
        0.0
    } else {
        sum / input.values.len() as f64
    };

    let output = OutputPayload { sum, mean, label: input.label };

    // Serialize back to a JS object — not a JSON string
    serde_wasm_bindgen::to_value(&output)
        .map_err(|e| JsValue::from_str(&e.to_string()))
}

JavaScript caller:

import init, { compute_stats } from './pkg/my_wasm.js';

await init();

// Pass a plain JS object — no JSON.stringify needed
const result = compute_stats({
  values: [10, 20, 30, 40],
  label: 'quarterly-revenue',
});

// result is already a JS object — no JSON.parse needed
console.log(result.sum);   // 100
console.log(result.mean);  // 25
console.log(result.label); // 'quarterly-revenue'

The serde-wasm-bindgen path eliminates two allocations (the JSON serialized string on the Rust side and the UTF-8 copy across the boundary) and two parse passes (JSON.stringify in JS and from_str in Rust). Benchmarks on a 1 KB payload with 100 fields show 3–5× lower latency compared to string-passing. The trade-off is a slightly larger compiled .wasm binary due to the additional deserialization visitor code.

Full example: Rust JSON processing with wasm-pack

The following walks through a complete project: a Rust crate that validates and transforms a JSON array, built with wasm-pack and imported as an ES module in a browser page. This is the canonical production pattern for JSON-heavy Wasm modules.

Project layout:

my-json-wasm/
├── Cargo.toml
├── src/
│   └── lib.rs
└── www/
    ├── index.html
    └── index.js      # imports from ../pkg/ after wasm-pack build

Cargo.toml:

[package]
name = "my-json-wasm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"

[profile.release]
opt-level = "z"   # minimize .wasm binary size
lto = true
panic = "abort"

src/lib.rs:

use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Clone)]
pub struct Record {
    pub id: u32,
    pub name: String,
    pub score: f64,
    pub active: bool,
}

#[derive(Serialize)]
pub struct FilterResult {
    pub matched: Vec<Record>,
    pub total_input: usize,
    pub total_matched: usize,
}

/// Filter active records above a score threshold.
/// Accepts and returns native JS objects via serde-wasm-bindgen.
#[wasm_bindgen]
pub fn filter_records(records_js: JsValue, min_score: f64) -> Result<JsValue, JsValue> {
    let records: Vec<Record> = serde_wasm_bindgen::from_value(records_js)
        .map_err(|e| JsValue::from_str(&format!("Deserialize error: {e}")))?;

    let total_input = records.len();
    let matched: Vec<Record> = records
        .into_iter()
        .filter(|r| r.active && r.score >= min_score)
        .collect();

    let result = FilterResult {
        total_matched: matched.len(),
        matched,
        total_input,
    };

    serde_wasm_bindgen::to_value(&result)
        .map_err(|e| JsValue::from_str(&format!("Serialize error: {e}")))
}

/// Panic hook — forwards Rust panics to the browser console.
#[wasm_bindgen(start)]
pub fn main() {
    console_error_panic_hook::set_once();
}

Build and use:

# Install wasm-pack (once)
cargo install wasm-pack

# Build for browser (ES module output)
wasm-pack build --target web --release

# Build for Node.js (CommonJS output)
wasm-pack build --target nodejs --release

# Output in pkg/: my_json_wasm.js, my_json_wasm_bg.wasm, my_json_wasm.d.ts
// www/index.js
import init, { filter_records } from '../pkg/my_json_wasm.js';

await init();

const records = [
  { id: 1, name: 'Alice', score: 92.5, active: true },
  { id: 2, name: 'Bob',   score: 61.0, active: true },
  { id: 3, name: 'Carol', score: 88.0, active: false },
  { id: 4, name: 'Dave',  score: 95.0, active: true },
];

const result = filter_records(records, 80.0);
console.log(`${result.total_matched} of ${result.total_input} records matched`);
// 2 of 4 records matched
result.matched.forEach(r => console.log(`  ${r.name}: ${r.score}`));
// Alice: 92.5
// Dave: 95

Add console-error-panic-hook = "0.1" to [dependencies] and call console_error_panic_hook::set_once() in your #[wasm_bindgen(start)] function so that Rust panics surface as readable messages in the browser DevTools console rather than an opaque Wasm trap.

TypeScript types for Wasm JSON functions

wasm-bindgen automatically generates a .d.ts TypeScript declaration file alongside the JavaScript bindings. Functions annotated with #[wasm_bindgen] appear in the declaration file with their parameter and return types inferred from Rust signatures. JsValue parameters become any in TypeScript — correct but not very helpful for callers.

Generated .d.ts (simplified):

// pkg/my_json_wasm.d.ts — auto-generated by wasm-bindgen
export function filter_records(records_js: any, min_score: number): any;
export function compute_stats(js_input: any): any;

To add precise types, use #[wasm_bindgen(typescript_custom_section)] in Rust to inject TypeScript declarations, or write a thin TypeScript wrapper that casts the return:

// Option 1: inject TypeScript from Rust
use wasm_bindgen::prelude::*;

#[wasm_bindgen(typescript_custom_section)]
const TS_TYPES: &'static str = r#"
export interface Record {
  id: number;
  name: string;
  score: number;
  active: boolean;
}
export interface FilterResult {
  matched: Record[];
  total_input: number;
  total_matched: number;
}
"#;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "Record[]")]
    pub type RecordArray;
    #[wasm_bindgen(typescript_type = "FilterResult")]
    pub type FilterResultType;
}

// Now use these opaque types in your function signature
#[wasm_bindgen]
pub fn filter_records_typed(records: RecordArray, min_score: f64) -> FilterResultType {
    // cast via JsValue internally
    let js: JsValue = records.into();
    // ... same logic as before
    unimplemented!()
}
// Option 2: TypeScript wrapper module (simpler)
import { filter_records } from './pkg/my_json_wasm.js';

interface Record {
  id: number;
  name: string;
  score: number;
  active: boolean;
}

interface FilterResult {
  matched: Record[];
  total_input: number;
  total_matched: number;
}

export function filterRecords(records: Record[], minScore: number): FilterResult {
  return filter_records(records, minScore) as FilterResult;
}

The TypeScript wrapper approach is simpler for most projects: write interfaces in TypeScript, cast the any return, and keep the wasm-bindgen output untouched. The typescript_custom_section approach is useful when you want the types to ship as part of the pkg/ directory so consumers of your Wasm module get types without needing a separate @types package.

Performance comparison: string passing vs serde-wasm-bindgen vs direct Wasm computation

The cost of JSON interop across the JS/Wasm boundary depends on payload size and call frequency. The table below summarizes the dominant cost for each approach on a modern desktop browser (Chrome 124, Apple M2):

Approach1 KB payload100 KB payloadMain overhead
String-passing (JSON.stringify + from_str)~1.5 μs~120 μsUTF-8 encoding + JSON parse ×2
serde-wasm-bindgen (JsValue)~0.4 μs~80 μsV8 property iteration
Pure Wasm computation (no JSON in/out)~0.02 μsN/AFunction call overhead only

For payloads under ~10 KB with moderate call frequency (fewer than ~5 000 calls/s), either approach is imperceptible to users. The performance gap matters in tight loops — for example, validating each record in a 50 000-row dataset. In that scenario, batch processing the entire array in a single Wasm call (as in the filter_records example above) eliminates the per-call overhead entirely.

A key principle: minimize the number of JS/Wasm boundary crossings, not just the per-crossing cost. Passing 10 000 individual records one by one is always slower than passing the full array in one call, regardless of the serialization approach. Design your Wasm API around batches and streaming rather than per-item function calls.

For deep optimization, consider using binary formats like CBOR instead of JSON for the Wasm boundary — CBOR is more compact and faster to parse than JSON text, and the ciborium Rust crate supports the same serde derive workflow as serde_json. See also our guide on JSON performance in JavaScript for a baseline comparison of JS-side JSON costs.

Use cases: JSON schema validation, data transformation, and cryptographic signing

Three categories of JSON workloads particularly benefit from WebAssembly:

JSON schema validation in the browser. Running a compiled Rust validator (using the jsonschema crate) against untrusted user input in the browser eliminates a server round-trip for validation feedback. The validator compiles once to Wasm, loads in ~50 ms, and validates a 10 KB document in under 1 ms — far faster than a typical network request. This is especially useful for form builders and API explorers where instant feedback improves UX. See our guide on parsing JSON in Rust for the serde_json patterns that underpin Wasm validators.

Data transformation pipelines. Reshaping, filtering, and aggregating large JSON datasets entirely in the browser avoids sending raw data to a server. A Wasm module can filter a 100 000-row JSON array in ~20 ms — fast enough for interactive analytics dashboards. The pattern is: load data once (fetch + JSON.parse), pass the JavaScript array to Wasm via serde-wasm-bindgen, process in Rust, return the result. Compare JSON-to-JSON transformation options in our JSON to Protobuf guide for when you want to change the wire format too.

Cryptographic JSON signing.HMAC-SHA256 or Ed25519 signing of JSON payloads requires crypto primitives that are either unavailable in plain JavaScript or slow due to JavaScript's lack of SIMD byte operations. Rust crates like hmac, sha2, and ed25519-dalek compile to Wasm and execute at near-native speed. The pattern: canonicalize the JSON (sort keys, strip whitespace) in Rust using serde_json, serialize to bytes, sign, and return the signature as a Base64 string. This keeps private keys out of JavaScript and produces deterministic signatures regardless of JS engine JSON serialization quirks. See also our guide on JSON vs Protobuf for when a binary-first format is better for signed payloads.

Validate your JSON before passing it to WebAssembly

Catch syntax errors in your JSON payloads before they hit your Wasm module and produce cryptic deserialization errors.

Open JSON Formatter

Definitions

WebAssembly (Wasm)
A binary instruction format for a stack-based virtual machine, designed as a portable compilation target for languages like Rust, C, and C++. Wasm modules run in a sandboxed environment inside browsers and Node.js, with access to a linear memory array and exported functions. Wasm cannot directly access the JavaScript heap or DOM without going through the JS API.
wasm-bindgen
A Rust crate and CLI tool that generates JavaScript and TypeScript bindings for Rust-compiled WebAssembly modules. It handles the boilerplate of converting between JavaScript types and Wasm-compatible primitives, enabling Rust functions to accept and return strings, arrays, and arbitrary JavaScript objects (viaJsValue).
JsValue
A wasm-bindgen type that represents a reference to any JavaScript value (object, array, string, number, boolean, null, or undefined) living in the JavaScript heap.JsValueis a handle, not a copy — the JS value stays in V8's memory; Rust holds a reference-counted pointer. It is the bridge type used when passing arbitrary JS objects to Rust without serializing to JSON first.
serde_json
The de facto JSON serialization crate for Rust, with 200M+ downloads on crates.io. Built on top of the serde framework, it provides from_str(), from_reader(), to_string(), and the json!() macro. It compiles to WebAssembly without modification and is used inside Wasm modules for string-based JSON parsing when the payload arrives as a UTF-8 string.
serde-wasm-bindgen
A crate that provides serde Serializer and Deserializer implementations backed by JsValue. Its from_value::<T>(JsValue) function deserializes a JavaScript object directly into a Rust type without producing a JSON string, and to_value(&T) serializes a Rust type back to a JavaScript object. This is the preferred interop approach for high-frequency calls with small payloads.
wasm-pack
A build tool for Rust-generated WebAssembly. Running wasm-pack build compiles the Rust crate to .wasm, runs wasm-bindgen to generate JS/TS glue code, and places the output in a pkg/ directory ready to publish to npm or import directly in a browser. The --target flag selects the module format: web for ES modules, nodejs for CommonJS, and bundler for webpack/Vite.
Linear memory
The WebAssembly memory model: a single contiguous, resizable byte array that a Wasm module uses for all heap allocations. Linear memory is separate from the JavaScript heap. Strings and byte arrays passed between JS and Wasm are copied into (or out of) this linear memory buffer — which is why the string-passing approach incurs a UTF-8 encoding and memory copy step that the JsValue approach avoids.

Frequently asked questions

How do I pass a JSON object from JavaScript to a Rust WebAssembly function?

Two approaches. String-passing: call JSON.stringify(obj) in JavaScript and pass the resulting string to a Wasm function that accepts &str, then parse with serde_json::from_str() in Rust.JsValue interop: annotate the Wasm function to accept JsValue, then call serde_wasm_bindgen::from_value::<T>(js_value) inside Rust to deserialize directly without a JSON string intermediary. The JsValue approach is 3–5× faster for small payloads because it skips UTF-8 encoding and the JSON string round-trip.

What is the difference between JsValue and serde_json::Value?

JsValue (from wasm-bindgen) is a handle to any JavaScript value living in the V8 heap — it can represent any JS object, array, string, number, boolean, null, or undefined. serde_json::Value is a Rust enum with six variants that models the JSON data model and lives entirely in Rust memory. They are not interchangeable: use JsValue when crossing the JS/Wasm boundary, and serde_json::Value when working with JSON data in pure Rust logic. You can convert between them using serde_wasm_bindgen::from_value and serde_wasm_bindgen::to_value.

How do I return a JSON object from a Rust Wasm function to JavaScript?

Two options. Return a String: call serde_json::to_string(&result)? in Rust, then JSON.parse(wasmResult) in JavaScript. Return a JsValue: call serde_wasm_bindgen::to_value(&result)? in Rust and annotate the function return type as JsValue — the caller receives a native JavaScript object without needing JSON.parse. The JsValue return is preferred for high-frequency calls; the string return is simpler to debug since you can inspect the JSON in the browser console.

Is it faster to pass JSON as a string or use serde-wasm-bindgen?

serde-wasm-bindgen is 3–5× faster than string-passing for small payloads (under ~10 KB) because it converts directly between V8 JavaScript objects and Rust types without JSON string serialization, UTF-8 encoding, or memory copying. For very large payloads (hundreds of KB), the gap narrows because both approaches are dominated by memory allocation. String-passing has lower setup complexity and works with only wasm-bindgen and serde_json, without the additional serde-wasm-bindgen dependency. See our JSON performance in JavaScript guide for the JS-side baseline.

How do I set up wasm-pack for a JSON processing project?

Install wasm-pack with cargo install wasm-pack. Create a Rust library crate and set crate-type = ["cdylib"] in [lib]. Add to Cargo.toml: wasm-bindgen = "0.2", serde = { version: "1", features: ["derive"] }, serde_json = "1", and optionally serde-wasm-bindgen = "0.6". Annotate your public functions with #[wasm_bindgen]. Run wasm-pack build --target web to produce an ES module and a .wasm file ready to import in a browser.

Can I use serde_json in a WebAssembly module?

Yes. serde_json compiles to WebAssembly without modification because it uses only the Rust standard library and no platform-specific system calls. Add serde_json = "1" to your Cargo.toml alongside wasm-bindgen. Use serde_json::from_str() to parse JSON strings passed from JavaScript, and serde_json::to_string() to serialize Rust types back to JSON strings. See our parse JSON in Rust with serde_json guide for the full serde_json API.

How do I generate TypeScript types for my Wasm JSON functions?

wasm-bindgen automatically generates a .d.ts TypeScript declaration file when you run wasm-pack build. Functions annotated with #[wasm_bindgen] appear with their parameter types — JsValue parameters become any. For precise types, either use #[wasm_bindgen(typescript_custom_section)] in Rust to inject TypeScript interface declarations into the generated .d.ts, or write a thin TypeScript wrapper module that imports the Wasm function, casts the any return to a concrete interface, and re-exports a type-safe function.

What are good use cases for JSON processing in WebAssembly?

JSON schema validation in the browser (instant feedback without a server round-trip), large dataset filtering and aggregation (process 100 000-row JSON arrays in ~20 ms), cryptographic JSON signing (HMAC-SHA256 or Ed25519 using Rust crypto crates at near-native speed), and parse-intensive analytics dashboards (deserialize and aggregate large JSON logs entirely in the browser). Wasm excels when the computation is CPU-bound, the JSON payload is large enough to amortize the Wasm startup cost (~50 ms first load, near-zero on subsequent calls), and you need consistent performance across all browsers. For simple one-off JSON transforms, JavaScript JSON transformation is often sufficient.

Further reading and primary sources