Dev log ยท 2026-07-26

SQLite in the browser that reads pages on demand ๐Ÿ—„๏ธ

Persistent SQLite in the browser usually means sql.js. sql.js loads the full database file into memory. Ordinary SQLite does not do this. Ordinary SQLite is demand-paged.

This demo keeps the demand-paged model in the browser. The pages of the database are in IndexedDB, and SQLite reads a page only when a query touches it. The same build includes FTS5 full-text search and CRDT merge. The demo runs from a file:// page that you open with a double click.

The objective

The objective is robust SQLite that runs in a plain .html file that you open at file://. It is demand-paged, and it has the extensions that we want. It uses no server, no network fetch, and no SharedArrayBuffer. Thus the COOP/COEP headers are not necessary, and a local file cannot send them. Double-click the file, and you have a real database.

Three properties must be true at the same time. Most browser-SQLite setups lose one of them:

Ordinary SQLite is demand-paged

SQLite does not load a file-backed database into memory. Its pager reads and writes single pages (4 KB by default) when a query touches them. The pager keeps only a small cache in RAM (PRAGMA cache_size). A 50 GB database runs in a few megabytes. Only a :memory: database is fully in RAM. This on-demand paging is the reason that SQLite operates on a very small device and also on a very large database.

sql.js holds the full file in one array

The browser gave WebAssembly no random-access file API that the pager of SQLite can drive. Thus sql.js keeps the file as one Uint8Array on the WASM heap. This is simple, and it is satisfactory for small data. But a 20 MB database uses 20 MB of RAM, and the load time increases with the size of the full file. A write serialises the full file again and stores it again. You exchange the best feature of SQLite for convenience.

A VFS puts it back, in approximately four lines

SQLite has an interface for exactly this: the VFS (virtual filesystem). Give SQLite a VFS that stores its blocks in IndexedDB. Then the block reads and writes of the pager go there, one page at a time. wa-sqlite includes 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 with the key [path, offset, version]. The commits are batch-atomic. Open DevTools โ†’ Application โ†’ IndexedDB in the demo, and you can see the pages appear. Figure 1 compares the two models.

sql.js compared with the IndexedDB VFS With sql.js, a query goes to one Uint8Array that holds the full file in RAM. With wa-sqlite, a query goes to the SQLite pager. The pager reads and writes one 4 KB page at a time through IDBBatchAtomicVFS into IndexedDB, where each page is one FileBlock. sql.js SQL query sql.js Full file in RAM one Uint8Array, WASM heap 20 MB file, 20 MB of RAM a write stores the full file wa-sqlite + IndexedDB VFS SQL query SQLite pager 4 KB pages, small cache one page at a time IDBBatchAtomicVFS IndexedDB one FileBlock for each page only the pages that a query touches
Figure 1. sql.js loads the full file into RAM. The pager with the IndexedDB VFS reads one page at a time.

Why it runs from file://

Two properties make this possible. Most persistent-SQLite options do not have them:

The build to use: it includes FTS5 and CRDT

The code above imports @vlcn.io/wa-sqlite, not plain wa-sqlite. The reason is important, because the names can cause a wrong conclusion. The vlcn package is a fork of wa-sqlite, and it statically links cr-sqlite. It includes the same IDBBatchAtomicVFS. The two copies are byte-identical, except for one commented-out import.

Thus the choice of the fork costs nothing in paging. Demand paging comes from the VFS, not from the build. The fork adds two items. Without the fork, you must compile them yourself:

CapabilitySource
Demand pagingIDBBatchAtomicVFS, the VFS, in each of the two packages
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 no FTS5 and no CRDT. Its PRAGMA compile_options lists no ENABLE_FTS5, and CREATE VIRTUAL TABLE โ€ฆ USING fts5 fails with no such module: fts5. The fork has both. Both packages set SQLITE_OMIT_LOAD_EXTENSION, so nothing can load at runtime. Each extension that you want must be in the binary. This is the reason that the correct prebuilt binary makes a toolchain unnecessary.

Is it still paged with the extensions? Measure it

It is easy to assert this. Thus the repo asserts it in a test. The test does these steps:

  1. Subclass the VFS.
  2. Count each xRead that the pager issues.
  3. Close the database, then open it again. Now the page cache is cold, and each read must come from IndexedDB.
234 pages in file, FTS5 query faulted in 17

A full-text search on a 234-page database touches 17 pages. That is approximately 7% of the file. A CRR and an FTS5 index are live in the same file. That is the full claim in one line, and it is a test, not a paragraph.

Four problems that cost time

Most of the value in the repo is these four items. Each one cost real debug time.

1. One connection cannot run two statements at the same time. This is the expensive one. A wa-sqlite connection is one Asyncify state machine. If two queries overlap on it, the await points in step() interleave, and the pager of SQLite loses synchronisation. A simple Promise.all, or a debounced search box in the middle of a refresh, causes that overlap. The pager takes duplicate read locks, then fails a hot-journal check on a journal that never existed.

Then the wasm aborts with memory access out of bounds. The symptom looks the same as VFS corruption, but it has no relation to the VFS. The fix is one promise queue for every statement. The engine does that now, so the callers can be as concurrent as they want.

Do not test a VFS with an in-memory IndexedDB substitute. That crash never occurred under Node with fake-indexeddb. The fake is fast enough to hide the overlap. The Node suite passed while the real page crashed at boot. The test that caught the crash drove the built file from a real file:// URL in headless Chromium.

2. cr-sqlite rejects NOT NULL without a DEFAULT. A replicated table must have a default on each column, so replicas on different schema versions can still exchange rows. It must also have a non-nullable primary key. body TEXT NOT NULL fails. Add DEFAULT '', and it passes.

3. An FTS5 table cannot be a CRR. crsql_as_crr rejects it, because of the nullable primary key on rowid, so the index is a derived local artifact. The content table replicates, and triggers maintain the FTS index on top of it. One result was not obvious, so we did not assume it: those triggers do fire for rows that arrive through a merge. Thus the index stays correct without a rebuild, and the test suite asserts that also. Figure 2 shows the two paths into the index.

The FTS5 index is a derived local artifact A local write and the rows from a merge both go into the content table, and the content table replicates as a CRR. Triggers fire for each row and maintain the FTS5 index. The FTS5 index is a derived local artifact, not a CRR, and it stays correct without a rebuild. A local write Rows from a merge Content table replicates as a CRR triggers fire for each row, also for rows from a merge FTS5 index a derived local artifact, not a CRR the index stays correct without a rebuild
Figure 2. Triggers maintain the FTS5 index from the content table, also for the rows that a merge brings.

4. No autoincrement ids under CRDT. Two offline replicas can each select max(id)+1. The two rows collide and merge into one row. Use random ids.

Two directions of "demand-paged"

The idea is the same, with two targets. Select by your task: a read or a write, local or remote:

Your taskVFSFetches
A local read/write database that persistsIDBBatchAtomicVFS (this demo)pages โ†” IndexedDB
A query on a large remote read-only databaseHTTP-range VFS (sql.js-httpvfs)only the pages that a query touches, through HTTP Range

The second is the way to serve a large static knowledge base in a .sqlite file. No visitor downloads the full file. The query gets a few kilobytes of pages, not the file.

If your data is small and you never write, use sql.js. When persistence, size, or write frequency is important, put a VFS under SQLite. That is the answer in the style of SQLite.

The step up: encrypted and access-controlled

The demo stops at convergence: two replicas merge, and each changeset is as trustworthy as the last. Encryption at rest, or a limit on who can read and write what, is the same VFS with more on top. localRbac wraps this exact IDBBatchAtomicVFS as an EncryptedIDBVFS, with transparent per-page AES. It also adds per-resource encryption keys, signatures that the merge checks, and Shamir threshold custody of the root secret. Start here for the mechanism. Go there for the full system.

What we have now: one binary, every extension

The published @vlcn.io/wa-sqlite has FTS5 and CRDT, but it has no vectors and no spatial support. rtree, geopoly, and vec0 are all absent. A probe of the binary confirmed this, not a read of a Makefile. To get them, you must build from source. Thus that build is now its own repo. It produces four variants, so the binary includes only the extensions that you use:

VariantFTS5CRDTR*TreeGeopolyvec0Size
baseโœ“โœ“โ€”โ€”โ€”1.77 MB
geoโœ“โœ“โœ“โœ“โ€”1.83 MB
vecโœ“โœ“โ€”โ€”โœ“1.94 MB
allโœ“โœ“โœ“โœ“โœ“2.01 MB

Geometry costs approximately 60 KB, and vectors cost approximately 170 KB. Both are small compared with the 1.77 MB minimum. But that minimum becomes approximately 2.7 MB after a base64 inline into an HTML file. Thus take the smallest variant that is sufficient.

Each variant declares itself as a drop-in @vlcn.io/wa-sqlite. It has the same package name and the same import specifiers. Thus, to adopt one variant, you change one dependency line, and you do not refactor: "@vlcn.io/wa-sqlite": "file:../sqlite-wasm-build/dist/vec". localRbac is the first project that runs on it. It runs on base, and its 46 RBAC tests pass against the locally built binary. Figure 3 shows the four variants behind one package name.

Four build variants behind one package name Your code imports @vlcn.io/wa-sqlite. One dependency line that points to a dist folder selects the variant. The variants are base (FTS5 and CRDT, 1.77 MB), geo (base with R*Tree and Geopoly, 1.83 MB), vec (base with vec0, 1.94 MB), and all (geo with vec0, 2.01 MB). Your code imports @vlcn.io/wa-sqlite same package name, same import specifiers One dependency line selects the variant "file:../sqlite-wasm-build/dist/<variant>" base FTS5, CRDT 1.77 MB geo base + R*Tree, Geopoly 1.83 MB vec base + vec0 1.94 MB all geo + vec0 2.01 MB take the smallest variant that is sufficient
Figure 3. Four variants, one package name: one dependency line selects the variant.

The extensions register through SQLITE_EXTRA_INIT. That macro names exactly one symbol, and cr-sqlite already claims it with core_init(). The build does not patch the submodule, but it concatenates a small file after it. That file chains the two steps: run the initializer of cr-sqlite, then register each other extension. R*Tree and Geopoly do not use that file, because they are in the SQLite amalgamation and -D flags alone turn them on. This is the reason that they are the correct spatial choice for this target.

For geometry, the useful answer is narrower than “use SpatiaLite”. Geopoly is in the amalgamation and has zero external dependencies. It gives indexed polygon predicates on top of R*Tree: geopoly_contains_point, geopoly_within, geopoly_overlap, and geopoly_area. It takes GeoJSON-style coordinate arrays, and those arrays pair well with JSON1. Geopoly has no projections, no geodesy, and no ST_* names, and it supports polygons only.

SpatiaLite gives you all of that, but it uses GEOS and PROJ to do it. A wasm port of those is a serious project, and it adds megabytes to the binary. The full point of this binary is one file that you open with a double click. If a project must have OGC semantics and reprojection, that is an argument for a server, not for this build.

A database GUI, in the same one file

The most convincing demonstration of a claim such as this is an application that usually must have a server. Thus the demonstration is SQLite Studio, a database GUI in a single index.html. It has a schema tree, a SQL editor with completion from the live schema, a results grid, CSV export, and a tab for ordinary CRUD. You can create a database, create tables, query them, and edit rows.

No server serves any part of it. Save the page and open it from file://, and it behaves identically. The reason is that the wasm is inline and the pages are in IndexedDB. The completion is schema-aware, not a keyword list. After each statement, Studio reads the schema again. Thus SELECT offers your tables, and notes. offers the columns of that table.

Studio has two details that are consequences of items above. A single-column primary key or rowid is necessary to address a row for an edit. Thus a view or a WITHOUT ROWID table without one stays read-only, and Studio says so. Studio refreshes the schema immediately after it runs a user query. Without a queue, that refresh causes the two-statements abort constantly. Thus each statement goes through one queue.

Problems that cost real time

The build against a current toolchain (Emscripten 6, Rust nightly) failed six times before it succeeded. None of the failures was self-explanatory. We record them here, because they will occur again:

Dependencies, and what forces a rebuild

This stack is a pinned pair, not a set of libraries that move forward independently. These are the parts that can change and make us evaluate the stack again:

DependencyPinned atWhat forces a new 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.0Test each upgrade against crsql_as_crr(), not only in the compiler. 3.45 passes the compiler and fails at runtime.
wa-sqlitea specific commitA change to its JS โ†” wasm calling convention, especially a change to int64 or to the heap views.
Rustnightly-2023-10-05cr-sqlite itself pins it. It is nightly-only because of -Z build-std. A cr-sqlite that drops that condition frees this.
Emscriptenhost emsdkIt caused two problems already (BigInt, heap views). Run the probe again after each upgrade, before you trust the upgrade.
sqlite-vecfollows upstreamPre-1.0. A template generates its header, so its build layout is not stable yet.

The guard against all of this is the same: a probe that executes SQL against the produced binary, not trust in compile flags. A test also asserts that each variant contains exactly its declared extensions and nothing else. A flag with no effect cannot pass that test.

References

More from the dev log