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.

A Rust iterator consumed by for...of JavaScript calls Symbol.iterator on the wrapper, which creates the Rust iterator object. Each loop step calls next on the wrapper, which asks Rust for the next item or batch. When Rust reports the end, the wrapper returns done true and frees the Rust object; an early break calls return, which frees it too. for (const x of results) Symbol.iterator wrapper creates Rust iter exported struct next() → Rust next_batch items or end done: true end of sequence free Rust object on end or return()

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.

Iterating one million rows from Rust Milliseconds to iterate one million small rows from a Rust iterator in JavaScript with one boundary call per row, with batches of 256 rows converted by serde-wasm-bindgen, and with numeric batches copied as typed arrays. ms for one million rows one call per row 410 ms batches of 256 (serde) 160 ms typed-array batches 22 ms

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.

Handling changes to data during iteration Snapshotting copies the data when iteration starts, so changes are invisible but memory is spent. Iterating by index is cheap but may skip or repeat items when data changes. Version checks detect changes and throw, which is strict and cheap but forces callers to restart. strategy cost behaviour when data changes snapshot at start memory for the copy sees the old data index cursor none may skip or repeat items version check one comparison per step throws an error

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. Early break leaks the Rust iterator. Use a generator with finally.
  • 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.

← Back to wasm-bindgen Deep Dive