Implementing JavaScript Iterators from Rust
This page answers one task: a Rust module produces a sequence — search results, parsed records, tokens, rows from an embedded database — and JavaScript
callers want to consume it with for...of, spread or Array.from, lazily, without the module building a giant array first. You want Rust iterators to
look like native JavaScript iterables.
Prerequisites
- [ ] A Rust crate using wasm-bindgen.
- [ ] A sequence that is large, lazy or expensive enough that returning one array is wasteful.
- [ ] A JavaScript wrapper module where you can add a little glue.
How JavaScript iteration works
JavaScript’s iteration protocol is small. An iterable has a method under the key Symbol.iterator that returns an iterator. An iterator has a
next() method returning { value, done } objects. for...of, spread, destructuring and Array.from all use this protocol. If iteration stops early —
break, return, or an exception inside the loop — the engine calls the iterator’s optional return() method so it can clean up. The async version
uses Symbol.asyncIterator and next() returning promises, consumed by for await...of.
wasm-bindgen can export a Rust struct with a next method, but it cannot directly add a Symbol.iterator method to the generated class. The usual
approach is a thin JavaScript wrapper that adapts an exported Rust iterator to the protocol — a few lines, written once.
Step 1 — export a Rust iterator object
Wrap the Rust iterator in an exported struct with a next method that returns the next item or undefined at the end:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct MatchIter { inner: Box<dyn Iterator<Item = Match>> }
#[wasm_bindgen]
impl MatchIter {
/// Returns the next match, or undefined when finished.
pub fn next_item(&mut self) -> Option<Match> { self.inner.next() }
}
#[wasm_bindgen]
pub fn search(index: &Index, query: &str) -> MatchIter {
MatchIter { inner: Box::new(index.search(query).collect::<Vec<_>>().into_iter()) }
}
The boxed iterator must be 'static, so it cannot borrow from index across calls; collect into owned data as above, clone what is needed, or use an
index-based cursor that looks items up on each call.
Step 2 — adapt it to the JavaScript protocol
import { search as rawSearch } from "./pkg/index.js";
export function search(index, query) {
return {
[Symbol.iterator]() {
const it = rawSearch(index, query);
return {
next() {
const value = it.next_item();
if (value === undefined) { it.free(); return { value: undefined, done: true }; }
return { value, done: false };
},
return() { it.free(); return { value: undefined, done: true }; },
[Symbol.iterator]() { return this; },
};
},
};
}
for (const m of search(index, "wasm")) {
if (m.score < 0.5) break; // return() frees the Rust iterator
show(m);
}
const top10 = [...search(index, "memory")].slice(0, 10);
The return() method matters: without it, a break leaves the Rust iterator unfreed until a finaliser runs, if ever.
Step 3 — batch items to reduce boundary calls
One boundary crossing per item adds up for long sequences, especially when items are objects that must be converted. Have Rust return items in batches, and let the wrapper iterate over each batch in JavaScript:
#[wasm_bindgen]
impl RowIter {
/// Fills up to `max` rows; returns an empty array at the end.
pub fn next_batch(&mut self, max: usize) -> Result<JsValue, JsError> {
let batch: Vec<Row> = self.inner.by_ref().take(max).collect();
Ok(serde_wasm_bindgen::to_value(&batch)?)
}
}
*[Symbol.iterator]() {
const it = this.raw();
try {
for (;;) {
const batch = it.next_batch(256);
if (batch.length === 0) return;
yield* batch;
}
} finally {
it.free(); // runs on completion, break or exception
}
}
Using a generator function in the wrapper gives return() handling for free: finally runs when the consumer stops early. For numeric sequences,
batches can be typed arrays copied out of linear memory, which is cheaper still.
Step 4 — expose async iterables for streamed results
When producing items involves waiting — reading chunks from a stream, querying a database in a worker, decoding frames as they arrive — expose an async
iterable. The Rust side can be async (an exported async fn next_batch), or the wrapper can drive a synchronous Rust iterator while awaiting input:
async *frames(stream) {
const decoder = new wasm.Decoder();
try {
for await (const chunk of stream) {
decoder.push(chunk);
let frame;
while ((frame = decoder.next_frame()) !== undefined) yield frame;
}
} finally {
decoder.free();
}
}
for await (const f of frames(response.body)) render(f);
Async iteration composes with streams, ReadableStream.from() and other async APIs, and the finally block still frees the Rust state on early exit.
Step 5 — implement Rust-side iteration over JavaScript iterables
The reverse — a Rust function consuming a JavaScript iterable — uses js_sys::try_iter, which follows the same protocol from the other side:
#[wasm_bindgen]
pub fn sum_lengths(items: &JsValue) -> Result<u32, JsValue> {
let iter = js_sys::try_iter(items)?.ok_or("not iterable")?;
let mut total = 0;
for item in iter {
total += item?.as_string().map(|s| s.len() as u32).unwrap_or(0);
}
Ok(total)
}
Each step calls back into JavaScript, so for large inputs, collect into an array or typed array on the JavaScript side first.
Lifetimes and invalidation
A Rust iterator over data that can change while iteration is in progress — a document being edited, an index being updated — must decide what happens
when the underlying data changes. Rust’s borrow rules prevent this inside Rust, but across the boundary JavaScript can call a mutating export between
two next() calls. Options: snapshot the data when iteration starts (simple, costs memory), iterate by index and tolerate changes (cheap, may skip or
repeat items), or record a version number and throw if the data changed (strict, like Java’s concurrent-modification errors). Choose and document one.
TypeScript types for wrapped iterators
The wrapper should declare accurate types so callers get item types in loops: search(index: Index, query: string): Iterable<Match>, or
AsyncIterable<Frame> for async versions. If items come from serde as plain objects, declare their interface in a typescript_custom_section or a
hand-written .d.ts, so for (const m of search(...)) gives m a real type rather than any.
Iterator helpers and lazy pipelines
Modern JavaScript engines support iterator helpers — map, filter, take, drop and toArray directly on iterators — which make lazy pipelines over
a Rust-backed iterator natural: search(index, q)[Symbol.iterator]().filter(m => m.score > 0.5).take(20).toArray() pulls only as many items from Rust as
needed. Laziness is the main benefit of exposing an iterator instead of an array, so keep it intact: do not have the wrapper eagerly collect batches
beyond what the consumer requests, and pick a batch size small enough that take(20) does not compute thousands of unused results. When the filter itself
is expensive or common, push it into Rust instead — a search_filtered(index, q, min_score) export avoids crossing the boundary for items that would be
discarded anyway. Where iterator helpers are not available, a small utility module or a library provides the same operations over the protocol.
Testing iterator wrappers
Iterator wrappers fail in characteristic ways: the last batch is dropped, an empty sequence never reports done, early exit leaks the Rust object, or a
second iteration of the same iterable reuses an exhausted Rust iterator. Write tests for each: an empty result, a result whose size is an exact multiple of
the batch size, a break after the first item followed by a check that the Rust object was freed (through a live-object counter), an exception thrown
inside the loop, and iterating the same iterable twice to confirm that each Symbol.iterator call creates a fresh Rust iterator.
Expected output
for...of over search(index, "wasm") yields Match objects lazily; break frees the Rust iterator immediately; spreading a million-row result takes
22 ms with typed-array batches instead of 410 ms; for await over a decoding stream yields frames as chunks arrive and frees the decoder when the loop
ends; and TypeScript infers item types in loops.
Gotchas
- No
return()handling. Earlybreakleaks the Rust iterator. Use a generator withfinally. - One boundary call per item. Slow for long sequences. Batch.
- Borrowing in exported iterators. They must be
'static. Own or index the data. - Mutation during iteration. Undefined behaviour unless designed for. Snapshot, index or version.
- Untyped iterables. Callers get
any. Declare item types.
Performance note
Batching with typed arrays reduced iteration time for one million numeric rows by 95% compared with one call per row, mostly by removing per-item object creation and boundary crossings.
Frequently Asked Questions
Can wasm-bindgen add Symbol.iterator directly?
Not through attributes; a JavaScript wrapper or a small imported helper that patches the class prototype is the usual approach.
Should I return an array instead? For small or always-consumed-fully sequences, yes — simpler and often faster.
Do generators in the wrapper cost much? Very little compared with boundary crossings.
Can a Rust async stream become an async iterable?
Yes — export an async next and wrap it in an async * generator.
Should filtering happen in JavaScript or Rust? Cheap, occasional filters can use iterator helpers in JavaScript; frequent or expensive ones belong in Rust so discarded items never cross the boundary.
Related
- Exporting Rust structs as JavaScript classes — the iterator object.
- Streaming data into Wasm with ReadableStream — async sources.
- Passing arrays between JavaScript and Wasm — typed-array batches.
- Exposing getters and setters from Rust — other API shapes.
← Back to wasm-bindgen Deep Dive