ferrovec: a Tiny Rust HNSW Vector Index That Runs Semantic Search Inside the Browser Tab

I wanted semantic search with no server — and every Rust HNSW crate refused to compile to WebAssembly. So I wrote ferrovec: a hand-rolled HNSW vector index whose only Rust dependencies are serde and postcard, that denies unsafe code crate-wide, uses no system randomness, and produces a wasm build the project reports at ~33 KB gzipped. This is the launch: the algorithm, the determinism, the compaction, and the leap into the browser — the WASM core, transformers.js auto-embedding on a Web Worker, OPFS persistence, and single-writer leader election across tabs.
I wanted semantic search that ran with no server. Not "a small server." No server — the vectors, the index, and the query all living inside a browser tab, working offline, with nothing leaving the machine. The plan was the boring, winning one: a fast core in Rust, compiled to WebAssembly, wrapped in a plain JavaScript API. The same pattern behind every WebAssembly app that actually shipped.
Then I went looking for a Rust HNSW crate to drop in, and every mature one refused to compile to wasm32-unknown-unknown. They hard-depend on rayon, or mmap-rs, or num_cpus — reasonable choices on a server, and immovable walls in the browser, where there are no threads to fork, no files to memory-map, and no CPUs to count. So I wrote my own. ferrovec is the result: a tiny, dependency-light approximate-nearest-neighbor index whose whole reason to exist is that it fits where the others don't.
What it is
ferrovec is a hand-rolled HNSW vector index — Hierarchical Navigable Small World, the same approximate-nearest-neighbor algorithm behind Pinecone, Weaviate, and Qdrant. It went up at 0.3.1 on both registries: the Rust core on crates.io, and the full browser package on npm. The design constraints were unusually strict, on purpose:
- Featherweight. The Rust core's only dependencies are
serdeandpostcard. The wasm build is reported at ~33 KB gzipped. - No
unsafeoutside one audited SIMD kernel —#![deny(unsafe_code)]is on crate-wide, and the single exception is a scoped, safety-commented SIMD128 path. - No system randomness. A deterministic seeded splitmix64 PRNG means there is no
getrandomin the tree, which is exactly what lets it land onwasm32-unknown-unknownwith no shims. - Incremental and portable. Upsert-style inserts, tombstoning removals, and a compact binary format with a versioned header — the same bytes reload natively or in the browser.
Here is the whole learning curve for the Rust core:
use ferrovec::{Hnsw, Metric, Config};
let mut index = Hnsw::new(4); // 4-dim, Cosine by default
index.insert("a", &[1.0, 0.0, 0.0, 0.0]).unwrap();
index.insert("b", &[0.0, 1.0, 0.0, 0.0]).unwrap();
index.insert("c", &[0.9, 0.1, 0.0, 0.0]).unwrap();
let hits = index.search(&[1.0, 0.0, 0.0, 0.0], 2).unwrap();
assert_eq!(hits[0].id, "a"); // nearest first
assert_eq!(index.len(), 3);
search hands back Neighbor { id, distance } values, nearest first. Three metrics are available, all expressed so that smaller means closer:
| Metric | Value | Pairs well with |
|---|---|---|
Cosine (default) | 1 - cos(a, b) (zero-norm ⇒ 1.0) | sentence embeddings |
Dot | 1 - dot(a, b) | already-normalized vectors |
L2 | squared Euclidean distance | raw coordinates |
How HNSW finds a neighbor without checking every vector
The reason an ANN index is fast is that it never scores your query against the whole dataset. HNSW builds a hierarchy of graphs. The bottom layer holds every node, densely connected to its neighbors. Each layer above is a sparser sample — fewer nodes, longer hops — like an express lane stacked over local streets. A search enters at the top, greedily walks toward the query until it can't get any closer on that layer, then drops down a level and repeats. By the time it reaches the dense bottom layer it's already in the right neighborhood, so only a handful of local comparisons remain.
ferrovec's core is one file, hnsw.rs, a little under 700 lines. Nodes carry their per-layer neighbor lists; the candidate queues order by distance with f32::total_cmp so a stray NaN can never scramble the heap. You tune the graph through Config:
let index = Hnsw::with_config(128, Config {
max_connections: 16, // M — neighbors kept per node per layer
ef_construction: 200, // candidate-list size while building
ef_search: 50, // candidate-list size while querying
metric: Metric::L2,
seed: 42,
});
Those three knobs are the whole recall-versus-speed dial: bigger M and ef mean a denser graph and more candidates examined — better recall, more work. Layer 0 is allowed up to 2 × M connections, which is the standard HNSW asymmetry.
Determinism is a feature, not a detail
Most index libraries reach for the operating system's random source to assign each new node its top layer. That single call is what makes them impossible to compile for the browser target — getrandom has no wasm32-unknown-unknown backend without a JavaScript shim you have to wire up yourself. ferrovec side-steps the whole problem by carrying its own splitmix64 PRNG seeded from Config::seed. There is no getrandom anywhere in the dependency tree.
The payoff is bigger than portability. Because the randomness is seeded, a build is reproducible: insert the same vectors in the same order with the same seed and you get byte-identical graphs on your laptop and in a user's browser. That property is what makes the next feature honest.
Removals, tombstones, and getting the memory back
Deleting from a navigable graph is genuinely tricky: yank a node out and you can sever the paths that other nodes relied on to stay reachable. ferrovec does the pragmatic thing — remove (and upserting a new vector onto an existing id) only tombstones the node. It stays in the graph for connectivity but is filtered out of every result. Correct, and cheap. The catch is that heavy churn slowly grows memory with dead nodes that no query will ever return.
So there's compact, which rebuilds the index in place from the live vectors only, and here is where determinism pays off: it rewinds the PRNG to Config::seed before rebuilding, so a compacted index is byte-for-byte identical to a fresh build of the same survivors inserted in the same order.
index.remove("drop"); // tombstoned — still occupying memory
index.compact(); // rebuild from live nodes only; PRNG rewound to seed
assert_eq!(index.len(), 1); // live count unchanged
assert!(index.contains("keep"));
assert!(!index.contains("drop")); // removed ids stay gone
// live search results are still correct afterward
The same bytes, native or browser
Persistence is a compact binary format with a versioned header — four magic bytes, FVEC, then a format version, then the payload. to_bytes gives you a Vec<u8>; from_bytes restores it. The point of the versioned header is boring and important: the same serialized index reloads natively or in the browser, and a future format change can be detected instead of silently misread.
let bytes = index.to_bytes().unwrap(); // FVEC header + payload
let restored = Hnsw::from_bytes(&bytes).unwrap();
// restored.search(...) == index.search(...)
The SIMD kernel, and the one place unsafe is allowed
Distance is the inner loop of everything an ANN index does, so it's the one place worth hand-optimizing. ferrovec keeps scalar dot and squared-L2 functions as the always-available correctness reference. On wasm32 + simd128 the public dispatch swaps in a hand-written SIMD128 kernel that accumulates in four independent lanes and folds them at the end — and that kernel is the only code in the crate permitted to use unsafe, under a scoped #[allow(unsafe_code)] with safety comments, while everything else lives under the crate-wide deny.
Four-lane accumulation isn't bit-identical to a strictly left-to-right scalar sum — under IEEE-754 the two can differ by a few units in the last place. That could worry you, so the crate pins it down: a wasm integration test asserts that search returns the same nearest id as a scalar brute-force computed alongside it. The few-ULP wobble never changes who wins.
Crossing into the browser
The Rust core exposes a FerrovecCore class through wasm-bindgen. Built with wasm-pack, it's the low-level surface — you bring your own embeddings as a Float32Array:
import { FerrovecCore } from "ferrovec";
const index = new FerrovecCore(384); // 384-dim vectors
index.insert("doc-1", myEmbedding); // Float32Array
const hits = index.search(queryEmbedding, 5); // [{ id, distance }, ...]
const bytes = index.toBytes(); // Uint8Array — persist anywhere
const restored = FerrovecCore.fromBytes(bytes);
That's powerful but it still asks you to produce embeddings yourself. The npm package closes that gap. On top of the wasm core it layers automatic embedding via transformers.js, a dedicated Web Worker so none of it runs on your main thread, and OPFS persistence — and the API collapses to three lines:
import { Ferrovec } from "ferrovec";
const db = await Ferrovec.open("notes"); // spawns the worker, loads the model
await db.insert("the cat sat on the mat"); // embed + index
const hits = await db.query("a napping kitten", 5); // → [{ id, text, score }, ...]
You give it text; it embeds, indexes, and searches, entirely in the tab. Each hit carries the original text back and a cosine-similarity score where higher is closer. There is no network call in that snippet and no server on the other end of one.
Surviving a reload — and surviving a second tab
By default an index is persisted to the Origin Private File System so it outlives the page. The worker opens ferrovec/<name>/index.bin through a FileSystemSyncAccessHandle, and writes are full snapshots debounced around 250 ms — each insert or remove marks the store dirty and schedules a single write-truncate-flush, with close() forcing one final synchronous flush before it lets go. Where sync access isn't available — Node, an insecure context, an unsupported browser — it degrades quietly to in-memory instead of crashing.
Then there's the problem that only shows up in real use: the user opens your app in a second tab. Two workers, one OPFS file. Early versions let the second tab silently fork into its own in-memory copy, and the two would drift apart with the second tab's writes never reaching disk. The fix in 0.3.x is a proper distributed-systems answer — single-writer leader election.
Every store worker asks for an exclusive Web Lock named ferrovec-leader:<name>. Exactly one tab wins it and becomes the leader: it opens the OPFS store and holds the only engine and the only writer. The other tabs become followers and proxy their insert / query / remove / size calls to the leader over a BroadcastChannel as correlation-id request/response round-trips. Because there is exactly one authoritative index, every tab sees every other tab's writes and none of them can diverge. You can check which role a handle took with db.role — 'leader', 'follower', or 'solo'. (OPFS sync handles and Web Locks both require a secure context, so persistence and coordination switch off gracefully off HTTPS.)
See it run
The clearest proof is the live demo: the real wasm core running semantic search over 24 sentence embeddings, entirely in your browser tab. There's no server and no network request — the wasm binary and the vectors are baked into a single HTML file. Type a query, watch it rank by meaning rather than keyword. That single self-contained page is the whole thesis of the project made concrete.
Where it stands
The roadmap is complete and both registries are on 0.3.1. The Rust core (the HNSW graph, the WASM boundary, the SIMD128 kernel, compaction) ships on crates.io; the browser package (transformers.js auto-embedding, OPFS persistence, the three-line API, and cross-tab leader election) ships on npm.
Two things this post deliberately does not contain: benchmarks and recall numbers. I haven't measured them, so I'm not going to quote them — the ~33 KB is the size the build reports, and everything else here is a property you can read straight out of the source or reproduce from a fresh clone with cargo build --target wasm32-unknown-unknown. For a young library, an honest description of what it is seemed better than a number I can't stand behind. The benchmarks are the obvious next post, and they'll come with the commands to reproduce them.
Find ferrovec
- ferrovec project site — the one-page tour
- Live demo — semantic search over 24 embeddings, 100% in your browser
- crates.io/crates/ferrovec ·
cargo add ferrovec· docs.rs - ferrovec on npm ·
npm install ferrovec - ferrovec on GitHub — README, source, and the demo
ferrovec is an independent open-source project (MIT). Every property described here was read from the repository at v0.3.1; none of the claims are benchmarks. transformers.js is a HuggingFace project used by the browser package.
Related Posts

crimson-crab: a Production-Grade Rust SDK for Claude — and Why tokio Leaves the Dependency Tree on wasm32
crimson-crab is a Rust SDK for Anthropic's Claude API: v0.1.0, 191 passing tests, zero clippy warnings, and a library that denies unwrap, expect and panic at compile time. This is the launch post: why tokio sits in the native dependency tree and is absent from the wasm32 one, why 113 of the 191 tests are the documentation, and what happens when a response arrives from a model the SDK has never heard of.

The Open-Weight Frontier Didn't Die — It Moved
Meta made open-weight LLMs a movement, then quietly went closed in 2026. But the open frontier didn't collapse — it moved, mostly to Chinese labs, and the gap to the best closed models shrank to roughly four months.

On-Device AI Just Got Real
For three years, on-device AI was a demo that almost worked. In June 2026 it stopped being one. Sparse models like Apple's AFM 3 and Google's Gemma 4 made intelligence large in flash, small in motion, free to run, and offline by default.