Benchmarks from a 10k-item OPFS vault
Scheduled9 min read By NT²
A large local vault is not proven by a slogan. It is proven by measuring the paths people actually use—first page after unlock, debounced search, FTS versus filter queries—and by being honest about what those numbers do not mean.
Benchmarks from a 10k-item OPFS vault
The claim is narrow: a vault that stays usable at about ten thousand structured items is something you can measure on the device—not something the cloud proves for you.
We care about first-page latency after unlock, the debounce path when someone types into search, the choice between FTS5 and ordinary filter queries, and the memory difference between a paged list window and a full-table hydrate. We do not care about inventing a single “always under X ms everywhere” number, and we refuse to dress local engineering runs as a product SLA.
This essay is a follow-up to the architecture already argued in public: the list must not load the attic into Svelte state, search must ask SQLite the right question, and the vault file belongs in the Origin Private File System with SQLite work off the UI thread. Those posts explain what we built. This one explains how we check that the stack still behaves when the fixture stops being a demo.
The constraint: marketing FPS is the wrong instrument
Performance storytelling for password managers and vaults usually collapses into one of three failures.
The FPS poster. A single millisecond number, no fixture description, no cold-versus-warm distinction, and an implication that every phone, every browser, and every vault size will match the screenshot. That number is not a measurement. It is a vibe with a unit.
The cloud proxy. Latency from an API edge is treated as vault speed. For a zero-knowledge, local-first product, that is category error. Optional sync may move ciphertext. It does not answer “how long until the first fifty list rows appear after unlock on this machine.” The attic is local. The interesting path is local.
The full-table coincidence. A demo vault with a few hundred items “feels fast” when the UI loads everything into reactive state. At ten thousand items, the same habit becomes unlock-as-spreadsheet-import. If you only ever benchmark the small vault, you never see the architecture fail.
So the useful question is not “how fast is NT²?” in the abstract. It is: on a large local fixture, which paths stay proportional to the working set, and which paths would quietly become proportional to vault size?
That is a methodology question before it is a numbers question. Without a frozen public lab report, the honest thing to publish is the method—what we measure, what the fixture looks like, and what any illustrative timings do not prove.
The design: measure paths, not slogans
We treat “10k-item OPFS vault” as an engineering fixture shape, not a marketing badge.
Fixture shape
The fixture we care about looks roughly like this:
- About ten thousand structured asset items in a per-vault SQLite database.
- The database file lives in the browser’s Origin Private File System (cooperative sync access), not as the primary store on IndexedDB.
- Items are structured slots—titles, categories, list previews, lifecycle flags—not a blank canvas of documents. Titles are list metadata. Payloads stay sealed until open.
- A large local seed exists for development and engineering runs so the vault can be grown past demo size without waiting for years of personal history. The point of the seed is reproducibility of shape, not a published lab certificate.
We are not claiming every user’s vault is ten thousand items. We are claiming the product’s list and search paths are designed so that when a vault reaches that order of magnitude, the UI does not have to become a second copy of the attic.
What we measure
Four measurements matter more than a single hero metric.
| Path | Question | Why it matters |
|---|---|---|
| First page after unlock / filter change | How long until the first page of thin list rows is ready for the virtual list? | Unlock and “open Credentials” should cost roughly a page query, not a full-table walk. |
| Search debounce path | After typing settles, how long until a new page of matches returns? | Keystrokes must not fire a query on every character; settled input must re-query SQLite, not filter a giant client cache. |
| FTS vs filter strategy | Does free-text take the FTS5 branch, and do pure category/lifecycle filters stay on ordinary indexed predicates? | Wrong strategy is a silent tax: FTS on exact filters, or in-memory title scans for free-text. |
Memory of listRows vs full table | Does resident list state stay related to the paged window, or grow with vault size? | Architecture proof: paged window versus full-table hydrate. |
Notice what is absent from that table.
We do not measure “frames per second of the brand animation.” We do not measure edge RTT and call it vault speed. We do not decrypt every payload and then congratulate ourselves for how fast the list scrolled. List rows stay thin: identity, category, title, timestamps, optional preview. Opening a record is a separate path.
How a run is framed
A useful engineering run records, at least:
- Browser and OS (and whether OPFS sync access is available—without it, the vault file story does not apply).
- Cold versus warm Worker. First open after process start pays WASM / Worker bring-up. Later pages inside an unlocked session are warmer. Mixing those without labeling them is how “47 ms” becomes a lie.
- Query class. First page with no text; first page with a category filter; debounced free-text with FTS; filter-only with FTS dark.
- Result window. Default page size is on the order of fifty rows. Counts come from SQL. The UI holds a capped resident window, not the match set of the entire attic.
- What stayed sealed. If a measurement required decrypting ten thousand payloads, it is not a list benchmark.
When we quote order-of-magnitude timings from local engineering runs, they are illustrative, not a service-level agreement. Hardware differs. Background tabs differ. Disk pressure differs. A mid-range laptop with a warm Worker is not a low-end phone after a cold start. Any number that cannot survive that sentence does not belong in a product claim.
flowchart TB
Fixture["~10k items in OPFS SQLite"]
Paths["Measured paths"]
First["First page latency"]
Debounce["Debounced search"]
Strategy["FTS vs filter choice"]
Memory["listRows window vs full table"]
Caveats["Hardware · browser · cold/warm Worker"]
Fixture --> Paths
Paths --> First
Paths --> Debounce
Paths --> Strategy
Paths --> Memory
Paths --> Caveats
The diagram is the whole point: paths in, caveats attached. A single floating millisecond with no edges is not a benchmark.
The trade-off: honesty costs a cleaner story
Publishing methodology instead of a FPS poster has real product costs.
We cannot ship a one-line boast. “Always under X ms on every device” would be easier to tweet. It would also be false the moment someone opens a cold session on constrained hardware, or the moment vault size and attachment metadata change the working set. Preferring honesty means the public story is longer: measure these paths; expect order-of-magnitude behavior; do not confuse local SQLite with cloud RTT.
We accept that numbers drift. Browser releases change OPFS and Worker scheduling. WASM load cost changes. Schema growth adds columns and indexes. A frozen screenshot from last quarter is not a contract. Continuous engineering runs matter more than a carved-in-stone blog figure.
We accept that some readers want a lab PDF. We may publish tighter lab notes later. Until then, the architectural guarantees are the ones already shipped in prose: paged list state, FTS-versus-filter strategy, OPFS for the vault file, SQLite in a Web Worker. Benchmarks audit those choices. They do not replace them.
We also accept a developer friction. Growing a vault to ten thousand items is deliberate work. Seed tooling exists so engineers can exercise the large-fixture shape. That is not the same as shipping a public reproducibility kit with pinned hardware and a signed report. Confusing those two is how methodology essays become fake certificates.
What we get back is a feedback loop that matches the architecture. If first-page latency starts tracking vault size instead of page size, the list path regressed toward full-table habits. If search hitch appears on every keystroke, debounce or Worker scheduling regressed. If filter-only gestures suddenly pay FTS costs, the strategy switch regressed. Those are actionable failures. A single marketing FPS number is not.
There is a second trade-off that is easy to miss: local-first performance is not “infinite RAM on the device.” Keeping the attic in OPFS SQLite is how the product stays offline and private. Keeping only a window in UI state is how the product stays calm. The benchmark is there to catch the moment someone “optimizes” by pulling the attic back into the tab.
What we refuse
Architecture and measurement share the same refusals.
We refuse marketing FPS as a substitute for path measurement. A number without fixture, cold/warm labeling, and query class is decoration.
We refuse “always under X ms everywhere.” That claim cannot survive browser, hardware, and session-state variance. We will not print it as product truth.
We refuse to imply the cloud benchmarks the vault. Edge latency, sync batch timing, and Durable Object wake-ups are interesting for optional Premium sync. They are not proof that a local list of ten thousand titles stays usable offline.
We refuse full-table hydrate as a benchmark shortcut. Timing “load everything, then measure scroll” proves the wrong architecture. The measurement must exercise paged listRows and virtual list behavior—or it is measuring a strawman.
We refuse to treat decrypt-everything as list speed. Opening one item may decrypt one payload. Scrolling must not.
We refuse unbounded resident windows dressed up as “infinite scroll done right.” If the window quietly becomes the attic, memory benchmarks will eventually confess what the UI already knows.
We refuse to publish precision we do not have. Order-of-magnitude illustrations from local engineering runs may appear in engineering discussion; they are not SLAs. Absence of a frozen public lab report is not license to invent one in Markdown.
These refusals keep “fast at 10k” from becoming a slogan that outruns the stack.
Measure the window, not the attic
A large vault stays honest when you measure the same boundaries the product ships.
First page after unlock should look like a page query against OPFS SQLite, with list work off the UI thread—not like importing the history of the attic into Svelte. Debounced search should re-ask SQLite, choosing FTS5 when free-text wins and ordinary filters when category and lifecycle already win. Resident state should track the window. Memory should not track vault size by default.
That is the same stack argued across the local-first essays: why vault SQLite lives in OPFS, not IndexedDB, SQLite in a Web Worker, never load the whole vault into Svelte state, and FTS5 for titles; table scan when filters win. For the product feeling at that scale, see Ten thousand items, still a vault and Searching ten thousand titles without lag.
If that measurement posture matches how you want a private vault to grow, you can try NT² Vault or read more at nt2.me.
Last updated 2026-11-05
Related stories
- FTS5 for titles; table scan when filters win
8 min read
- Schema v60 and incremental migration
8 min read
- Never load the whole vault into Svelte state
8 min read