Skip to content

Never load the whole vault into Svelte state

8 min read By NT²

A local vault can hold thousands of structured items without becoming a giant in-memory array. The durable store stays in SQLite. The UI holds only a paged window of lightweight list rows, rendered through a virtual list.

Never load the whole vault into Svelte state

The claim is simple: the interactive vault list must not load every item into Svelte state.

SQLite can hold ten thousand structured assets. That does not mean the unlocked UI should hold ten thousand reactive objects, ten thousand decrypted payload envelopes, or ten thousand DOM nodes. The durable truth lives in the vault database. The list surface should ask for pages of lightweight rows, keep a bounded window in memory, and virtualize what the user can actually see.

That sounds obvious until you ship the shortcut. The shortcut is one function that returns every row, one array assigned into reactive state, and a template that loops the array. It works on day one. It becomes the wrong architecture as soon as the vault stops being a demo.

The constraint: full-table UI state fails for the wrong reasons

A vault list is not a marketing screenshot with forty items. It is a long-lived local database with filters, search, category counts, create and update churn, trash and archive lifecycles, and encrypted payloads that should stay sealed until a user opens a record.

If unlock means “read the whole table into memory,” several costs arrive together.

Memory grows with vault size, not with viewport size. A list row that looks small still carries identity, category, title, timestamps, and often a preview string. Multiply that by every active item, then keep the array hot in a reactive runtime. Add decrypted fields “just for convenience,” and the cost stops being theoretical. The browser is now holding a second copy of the attic.

Reactivity amplifies work that SQL already finished. When the UI owns the full set, every filter change, sort change, soft refresh, or insert can walk a huge array again. The database already answered the question with an indexed query. Re-answering it in JavaScript is duplicate labor paid on the main thread.

Unlock latency becomes proportional to vault size. Users expect opening a vault to feel like opening a local app, not like importing a spreadsheet. A first paint that waits for every row is a tax on every session, including the sessions where the person only needed one credential.

DOM pressure follows the same mistake. Even if memory were free, painting thousands of list cells is still work. Scroll jank is not a styling problem when the root cause is “we mounted the whole result set.”

Security posture softens under convenience. List views do not need ciphertext or unwrapped content keys. They need enough metadata to navigate. If the default list path decrypts or retains heavy payloads for every item, the product spends unlock time and RAM on data the scroll surface never shows.

The failure mode is not that SQLite cannot store large vaults. The failure mode is treating the UI state layer as if it were the database.

Local-first does not mean “keep everything in the tab.” It means the device owns the source of truth, and each layer of the client holds only what its job requires.

The design: page in SQL, window in state, virtualize in the DOM

NT² separates three responsibilities.

  1. SQLite answers count and page queries. Filters, search text, category, and lifecycle constraints become a query. The repository returns a page of list rows plus a total count and a hasMore flag. Default page size is fifty rows. The query path can use full-text search when text matching wins, or ordinary table filters when category and similar constraints are enough.
  2. UI state holds a sliding window of list rows. After unlock or a filter change, state loads the first page into listRows. Scrolling near the end requests the next page and appends. The resident window is capped so endless scroll does not quietly rebuild a full-table array under another name. Creates, updates, and deletes patch the window when they intersect it, instead of forcing a complete reload for every mutation.
  3. The list component virtualizes the viewport. Only the visible slice of the window becomes DOM. Scroll position and a load-more threshold decide when to ask state for another page. The user experiences a continuous list; the runtime experiences a bounded working set.

The row shape matters as much as the paging.

A list row is deliberately thin: identifier, category, title, update time, and an optional preview. It is not the encrypted payload. It is not the attachment ciphertext. Opening an item is a separate path that decrypts what that item needs. The scroll surface stays a navigation index over sealed storage.

flowchart LR
  UI[Virtual list UI]
  State[Paged list state]
  Repo[SQLite page queries]
  DB[(vault.sqlite)]

  UI -->|scroll filter search| State
  State -->|count + page| Repo
  Repo --> DB

That pipeline has a second, intentional asymmetry.

Backup and export still need a full traversal. Moving a vault to another device, or building a portable snapshot, is a batch job over the complete dataset. That path may scan every item. It is not the interactive list path. Confusing the two is how “temporary” full-table loads become the architecture again: someone reuses the export helper to populate the dashboard because it already returns everything.

So the rule is sharp. Full-table enumeration is a transition tool for backup and similar batch work. The unlocked list is always paged.

Category counts and search follow the same discipline. Counts are aggregate queries, not array.length on a hydrated universe. Search debounces and returns pages for the current filter set. The UI never needs every matching row in memory at once to feel searchable.

The trade-off: more state machinery, clearer scale behavior

Paging is more code than const items = await listAll().

The state layer must track offsets, window starts, filter identity, soft refresh versus hard reset, and whether another page is already in flight. Virtualization needs stable row heights or careful measurement. Mutations have to decide whether a changed item belongs in the current window, should be prepended, or should simply bump a count. Filter changes invalidate the window. A naive “always reload from zero” is correct but can feel flashy; a soft refresh that preserves context is nicer and harder.

We accept that complexity.

What we get back is scale behavior that matches how people use a vault. Opening the app costs roughly a first page, not the whole history of the attic. Scrolling costs another page when needed. Searching re-queries the database instead of filtering a giant client-side cache that may already be stale. Memory stays related to the working set. The main thread spends less time allocating and diffing objects the user cannot see.

There is also an honesty trade-off. We do not claim the list is “fully loaded.” Total counts come from SQL. The scrollbar and load-more behavior reflect a window over a larger set. For a local database product, that honesty is better than pretending the browser tab contains the entire vault as a JavaScript array.

Engineers sometimes worry that paging will make offline local data feel like a slow network API. In practice the opposite happens. Local page queries are cheap. What feels slow is hydrating too much, then asking the reactive system to own it. Keeping SQL close and the window small is how a local-first vault stays responsive as it grows.

What we refuse

Architecture is clearer when the refusals are explicit.

We refuse to drive the vault UI from a full-table load. If a feature needs a new filter, it extends the query path and consumes pages. It does not resurrect an all-rows helper as the list’s source of truth.

We refuse to treat backup enumeration as the list architecture. Export may walk everything. The dashboard may not. Reusing the batch path for interactive state is a regression dressed up as reuse.

We refuse to keep decrypted item payloads in list state “for speed.” Speed for scrolling comes from thin rows, indexes, and virtualization. Decrypt on open. Seal again when the session locks.

We refuse unbounded resident windows. Endless scroll without a cap quietly recreates full-table memory pressure. The window can grow while the user explores, then stay bounded.

We refuse to make the DOM the database. Mounting every cell is not a substitute for pagination, even when the data is already local.

These refusals are not anti-convenience. They are how a structured vault remains usable in year three, when the item count stops being cute and starts being real.

Keep the attic on disk, keep the list as a window

A local-first vault still needs ordinary product virtues: fast unlock, calm scrolling, search that does not hitch, and filters that feel immediate. Those virtues do not come from stuffing the attic into Svelte state. They come from letting SQLite remain the attic, letting list state remain a window, and letting the DOM render only what the eye can use.

That is the same stack story as storing the vault database in a real file-oriented browser store and keeping the product useful without a mandatory server round trip. The storage layer holds durable structure. The query layer pages it. The UI layer refuses to become a second, weaker database.

For the product feeling of a large vault that stays usable, read Ten thousand items, still a vault. Earlier in this series: why a PWA can be a local-first, zero-server vault and why vault SQLite lives in OPFS, not IndexedDB.

If that model fits how you want a private vault to work, you can try NT² Vault or read more at nt2.me.

Last updated 2026-08-19

Related stories