Dev log · 2026-07-26

SQLite that persists without hoarding RAM 🗄️

Persistent SQLite in the browser almost always means sql.js, which loads the entire database file into memory. Real SQLite doesn't work that way — it's demand-paged. Here's the version that keeps the demand-paged model in the browser: SQLite whose pages live in IndexedDB, read on demand — with FTS5 full-text search and CRDT merge in the same build — running from a double-clicked file:// page.

The objective

Robust SQLite — demand-paged, with the extensions we actually want — running inside a plain .html file opened at file://. No server, no network fetch, no SharedArrayBuffer, and therefore none of the COOP/COEP headers a local file can't send. Double-click the file and you have a real database.

Three things have to hold at once, and most browser-SQLite setups give up one of them:

Ordinary SQLite is demand-paged

A file-backed SQLite database is not loaded into memory. Its pager reads and writes individual pages (4 KB by default) as queries touch them, keeping only a small cache in RAM (PRAGMA cache_size). A 50 GB database runs in a few megabytes; only a :memory: database lives entirely in RAM. That on-demand paging is the whole reason SQLite scales down to a wristwatch and up to a warehouse.

The browser broke that — sql.js hands it back as one big array

The browser gave WebAssembly no random-access file API that SQLite's pager could drive, so sql.js fakes the file as a single Uint8Array on the WASM heap. Simple, and fine for small data — but a 20 MB database is 20 MB of RAM, load times scale with the whole file, and writes mean re-serialising and re-storing the lot. You've traded SQLite's best trick for convenience.

A VFS puts it back — in about four lines

SQLite has a seam for exactly this: the VFS (virtual filesystem). Give it one backed by IndexedDB and the pager's block reads and writes land there, one page at a time. wa-sqlite ships that VFS — IDBBatchAtomicVFS:

import * as SQLite from '@vlcn.io/wa-sqlite';
import SQLiteESMFactory from '@vlcn.io/wa-sqlite/dist/crsqlite.mjs';
import { IDBBatchAtomicVFS } from '@vlcn.io/wa-sqlite/src/examples/IDBBatchAtomicVFS.js';

const module  = await SQLiteESMFactory({ wasmBinary });          // inlined wasm → no fetch
const sqlite3 = SQLite.Factory(module);
sqlite3.vfs_register(new IDBBatchAtomicVFS('mydb'), true);        // IndexedDB is the "disk"
const db = await sqlite3.open_v2('demo.db');                      // demand-paged from here

Each SQLite page becomes one IndexedDB object — a FileBlock keyed [path, offset, version], with batch-atomic commits. Open DevTools → Application → IndexedDB in the demo and you can watch the pages appear.

Why it runs from file://

Two properties, and they're the ones most persistent-SQLite options miss:

Which build: FTS5 and CRDT come for free

The snippet above imports @vlcn.io/wa-sqlite rather than plain wa-sqlite, and it's worth being precise about why, because the naming invites a wrong conclusion. vlcn's package is a fork of wa-sqlite that statically links cr-sqlite. It ships the same IDBBatchAtomicVFS — byte-identical but for one commented-out import — so choosing it costs nothing in paging. Demand paging comes from the VFS, not from the build. What the fork adds is two things you'd otherwise compile yourself:

CapabilityWhere it comes from
Demand pagingIDBBatchAtomicVFS — the VFS, in either package
FTS5 (bm25, snippet)-DSQLITE_ENABLE_FTS5, already in the published wasm
CRDT (crsql_as_crr, crsql_changes)cr-sqlite, statically linked

Upstream wa-sqlite has neither FTS5 nor CRDT — PRAGMA compile_options lists no ENABLE_FTS5, and CREATE VIRTUAL TABLE … USING fts5 fails with no such module: fts5. The fork has both. Since SQLITE_OMIT_LOAD_EXTENSION is set in both, nothing can be loaded at runtime: whatever you want has to be in the binary, which is exactly why picking the right prebuilt binary saves you a toolchain.

Is it still paged with all that on top? Measure it

Easy to assert, so the repo asserts it in a test instead. Subclass the VFS, count every xRead the pager issues, then close and reopen the database so the page cache is cold and every read has to come back out of IndexedDB:

234 pages in file, FTS5 query faulted in 17

A full-text search against a 234-page database touches 17 pages — about 7% of the file — with a CRR and an FTS5 index live in the same file. That's the whole claim in one line, and it's a test, not a paragraph.

Four things that bite

Most of the value in the repo is these four, each of which cost real debugging time.

1. One connection cannot run two statements at once. This is the expensive one. A wa-sqlite connection is a single Asyncify state machine. Overlap two queries on it — an innocent Promise.all, or a debounced search box landing mid-refresh — and the await points inside step() interleave, desynchronising SQLite's pager. It takes duplicate read locks, then fails a hot-journal check against a journal that was never there, and the wasm aborts with memory access out of bounds. The symptom looks like VFS corruption and has nothing whatsoever to do with the VFS. The fix is to funnel every statement through one promise queue, which the engine does now, so callers can be as concurrent as they like.

The tests that mattered. That crash never reproduced under Node with fake-indexeddb — the fake is fast enough to hide the interleaving. The Node suite passed green while the real page crashed on boot. What caught it was driving the built file from an actual file:// URL in headless Chromium. If you take one operational lesson from this post: an in-memory IndexedDB stand-in does not test a VFS.

2. cr-sqlite rejects NOT NULL without a DEFAULT. A replicated table needs a default on every column — so replicas on different schema versions can still trade rows — and a non-nullable primary key. body TEXT NOT NULL fails; add DEFAULT '' and it's fine.

3. An FTS5 table can't itself be a CRR. crsql_as_crr refuses it, on the nullable rowid primary key. So the index is a derived local artifact: the content table replicates, and triggers maintain the FTS index on top of it. The good news, and it wasn't obvious enough to assume: those triggers do fire for rows that arrive through a merge, so the index stays correct without a rebuild. That's asserted in the test suite too.

4. No autoincrement ids under CRDT. Two replicas offline, both picking max(id)+1, collide and merge into a single row. Use random ids.

Two directions of "demand-paged"

Same idea, two targets — pick by whether you're reading or writing, local or remote:

You want…VFSFetches
A local read/write DB that persistsIDBBatchAtomicVFS (this demo)pages ↔ IndexedDB
To query a big remote read-only DBHTTP-range VFS (sql.js-httpvfs)only the pages a query touches, over HTTP Range

The second is how you'd serve a large static .sqlite knowledge base without making every visitor download it whole — the query pulls a few kilobytes of pages, not the file.

Rule of thumb. If your data is small and you never write, sql.js is fine. The moment persistence, size, or write frequency matters, put a VFS under it — that's the SQLite-shaped answer.

The step up: encrypted & access-controlled

The demo stops at convergence: two replicas merge, and any changeset is as trustworthy as the last. Wanting it encrypted at rest, or wanting to constrain who may read and write what, is the same VFS with more on top. localRbac wraps this exact IDBBatchAtomicVFS as an EncryptedIDBVFStransparent per-page AES — and adds per-resource encryption keys, signatures verified at merge, and Shamir threshold custody over the root secret. Start here for the mechanism; go there for the full apparatus.

What we have now: one binary, every extension

The published @vlcn.io/wa-sqlite has FTS5 and CRDT but no vectors and no spatial support — rtree, geopoly and vec0 are all absent, confirmed by probing the binary rather than reading a Makefile. Getting them means building from source, so that is now its own repo, producing four variants so you can pay only for what you use:

VariantFTS5CRDTR*TreeGeopolyvec0Size
base1.77 MB
geo1.83 MB
vec1.94 MB
all2.01 MB

Geometry costs about 60 KB and vectors about 170 KB — both cheap against the 1.77 MB floor. But that floor becomes roughly 2.7 MB once base64-inlined into an HTML file, so take the smallest variant that does the job.

Registration hangs off SQLITE_EXTRA_INIT, which names exactly one symbol — and cr-sqlite already claims it with core_init(). Rather than patch a submodule, a small file is concatenated after it and chains: run cr-sqlite's initializer, then register everything else. R*Tree and Geopoly need nothing there at all; they live in the SQLite amalgamation and are enabled purely by -D flags, which is exactly what makes them the sane spatial choice for this target.

On geometry specifically, the useful answer is narrower than “use SpatiaLite”. Geopoly ships inside the amalgamation with zero external dependencies and gives real indexed polygon predicates — geopoly_contains_point, geopoly_within, geopoly_overlap, geopoly_area — on top of R*Tree, taking GeoJSON-style coordinate arrays that pair well with JSON1. What it isn't: no projections, no geodesy, polygons only, no ST_* names. SpatiaLite gives you all of that and needs GEOS and PROJ to do it — a serious wasm porting project that would add megabytes to a binary whose whole point is arriving as one double-clickable file. If a project truly needs OGC semantics and reprojection, that argues for a server, not for this build.

A database GUI, in the same one file

The most convincing demonstration of a claim like this is an application you'd otherwise expect to need a server. So: SQLite Studio — a database GUI in a single index.html. Schema tree, SQL editor with completion fed from the live schema, results grid, CSV export, and a tab for ordinary CRUD. Create a database, create tables, query them, edit rows.

Nothing about it is served. Save the page and open it from file:// and it behaves identically, because the wasm is inlined and the pages are in IndexedDB. The completion is schema-aware rather than a keyword list — after each statement it re-introspects, so SELECT offers your tables and notes. offers that table's columns.

Two details it forced, both consequences of things above. A single-column primary key or rowid is needed to address a row for editing, so views and WITHOUT ROWID tables without one are read-only and say so. And because Studio refreshes the schema immediately after running a user query, it would trip the two-statements-at-once abort constantly — every statement goes through one queue.

Gotchas that cost real time

Building this against a current toolchain (Emscripten 6, Rust nightly) failed six times before it worked, and none of the failures were self-explanatory. Recording them because they will recur:

Dependencies, and what would force a rebuild

This stack is a pinned pairing, not a set of libraries that float forward independently. Worth knowing which moving parts would make us re-evaluate:

DependencyPinned atWhat would force a re-evaluation
cr-sqlitev0.16.3A release that compiles for wasm again, or one that pairs with a newer SQLite. Until then main is not an option.
SQLite3.44.0Any upgrade has to be tested against crsql_as_crr(), not just compiled — 3.45 passes the compiler and fails at runtime.
wa-sqlitea specific commitChanges to its JS ↔ wasm calling convention, especially anything touching int64 or heap views.
Rustnightly-2023-10-05Pinned by cr-sqlite itself, and nightly-only because of -Z build-std. A cr-sqlite that drops that requirement would free this.
Emscriptenhost emsdkAlready bitten us twice (BigInt, heap views). Each upgrade needs the probe re-run before it is trusted.
sqlite-vectrackingPre-1.0. Its header is generated from a template, so its build layout is not stable yet.

The guard against all of it is the same: a probe that executes SQL against the produced binary rather than trusting compile flags, plus a test asserting each variant contains exactly its declared extensions and nothing else. A flag that was set but had no effect cannot fool that.

References

More from the dev log