lab.www.hannesmoser.at ↗
022026-07-31case study

Postindex

A filter engine that runs in the browser tab. Rust on wasm32, posting lists as roaring containers, immutable segments over a virtual filesystem on OPFS.

Open the live projectpostindex.hannesmoser.at

The workload is filters. An issue tracker holds a few hundred thousand documents and almost every read is a conjunction over them: two tags, one of three statuses, an assignee, a date window. The documents themselves are small. What costs you is deciding which ones come back.

So this is a filter engine before it is a database. Exact fields become posting lists held as roaring-style containers, sparse ones as sorted arrays and dense ones as bitmaps, promoted at 4096 entries. Intersection dispatches per container pair instead of materialising one dense bitmap, and a query can stream matching ids without ever building one. Segments are immutable and merge LSM-style. The catalog is a plain B-tree map, because it is small and stays in memory.

Against IndexedDB the argument is short. It is a key/value store with no way to intersect two filters, so you fetch candidates and filter them in JavaScript. Against SQLite compiled to WebAssembly it is much narrower, and not all in one direction: SQLite gives you a planner, joins and range scans today, and this gives you none of those yet. What it gives instead is a bitmap-shaped intersection path where a row store walks tuples.

The costs are real. Rust on wasm32 with no third-party crates means the codecs, the checksums and the WAL framing are all hand written. A virtual filesystem six calls wide sits between the engine and OPFS, because wasm cannot hold a file handle itself, and the browser can kill the tab mid-write with no warning.

the write pathmain threadenvelopeengine · rustvalidate + frame spanswal worker · tsappend + strict flushopfsengine · rustprivate HOT deltaat the same timepublish + ackin commit sequenceneither lane publishes alone · the commit waits for both
Rust frames the record before the WAL worker may append it, then builds the HOT delta while that flush is in flight. The client hears nothing until both have finished.
where it isexact filtersdurable writesimmutable segmentscompactionquery planmulti-tabranges, sortfull-textworkingpartialspecified

Ranges are the next structure, and the plan is a BKD tree. The spec pushed back on picking one, on the grounds that reaching for a tree because established search engines use one is not by itself a reason, and that a browser, OPFS and a bitmap-heavy workload might want a different layout. BKD answers that on its own terms rather than by precedent: it bulk-loads into immutable blocks and merges the way the segments here already merge, which is the shape the engine is in anyway. Full-text sits behind it, specified, with no tokenizer yet.

stack
Rust (wasm32) · TypeScript · OPFS
query
11.41 ms p95 at 2,048 docs
replay
87,110 records · 49,769 reads
started
2026-07-31
status
experimental · no stable API