@evolu/sqlite-wasm
v3.54.0-build2
Published
SQLite compiled to WebAssembly, with Evolu's own TypeScript layer, which also encrypts databases
Readme
Evolu SQLite Wasm
This package is SQLite compiled to WebAssembly with Evolu's own minimal TypeScript layer for loading and using it. The layer also encrypts databases.
Why our own TypeScript
SQLite's WebAssembly build ships a general-purpose JavaScript layer. It's about 15,000 lines of source covering the whole C API, several storage VFSes, and a worker API, plus Emscripten's generated runtime.
We replace that JavaScript with our own, written to be as correct as possible. It's TypeScript throughout, with branded pointer types and typed errors, instead of loosely typed JavaScript with hand-written declarations. We ported the tests of the SQLite team and of wa-sqlite for what this package ships. We also wrote our own tests for the failure paths that matter for local-first data, such as recovery after an interrupted transaction, storage running out, and lock conflicts between connections. They run in CI in Chromium, Firefox, and WebKit.
We reimplement SQLite's JavaScript strictly test-first: red, green, refactor. Every behavior starts as a failing test, ported from the SQLite team's or wa-sqlite's tests or written for our failure paths. Only then do we add code, the least that makes the test pass, and we clean it up while the tests stay green.
These failure paths are real. While preparing this package in September 2026, we found that opfs-sahpool, the VFS Evolu relies on, never rolled back a transaction interrupted by closing a tab. The next open saw it half-applied. Fixing that uncovered a second problem: overlapping connections in one worker could silently lose committed data. Evolu opens only one connection, but apps and libraries that open several were exposed. Now they get SQLITE_BUSY instead. We reported both, and the SQLite team fixed them within a day, in SQLite 3.54.0. The forum thread has the details and reproductions.
We also asked for SQLITE_FULL when the browser says storage is full, so an app can tell its users instead of showing a generic disk I/O error. The SQLite team declined. Their JavaScript avoids browser-specific behavior, so every failed write stays SQLITE_IOERR. But the main signal isn't browser-specific. The File System Standard requires a sync access handle's write() and truncate() to throw QuotaExceededError when the quota would be exceeded. Our layer reports SQLITE_FULL for it and for Firefox's short writes. We test that with a real quota in Chromium and Firefox.
Every public C function the WebAssembly exports can be imported as a standalone function from @evolu/sqlite-wasm/c-api, so an app pays only for the functions it uses. We generate them from SQLite's own signature table and check them against the binary. The variadic sqlite3_config, sqlite3_db_config and sqlite3_vtab_config go through the build's fixed-argument shims, as in SQLite's JavaScript. A small loader replaces Emscripten's generated runtime. Together, that makes the JavaScript much smaller. The layer is synchronous, and the high-level API returns typed results.
Of SQLite's storage VFSes, we keep only opfs-sahpool. Evolu already uses it, and it fits Evolu best. It's synchronous, reading and writing through OPFS sync access handles directly in the worker, without a helper worker. It doesn't need cross-origin isolation. SQLite's "opfs" VFS needs SharedArrayBuffer, which requires the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers and the restrictions they bring. opfs-sahpool needs neither. And it's the fastest. SQLite's documentation calls it the highest-performing of its OPFS VFSes, clearly so for batch operations.
The catch is that only one tab or worker can have a database open at a time. As SQLite documents for opfs-sahpool, one pool may use a directory at a time, across workers and wasm instances. So opens of a directory must be serialized, and Evolu does that with its leader Web Lock. Otherwise, two openers of a new or empty directory can each create their own slots. On the next open, only one version of a file shows, and the other is kept hidden and never reused. We keep opfs-sahpool's slots byte for byte, so existing databases keep working. A database uses the pool only through the SqliteVfs and SqliteEncryptingVfs interfaces. So if Evolu ever needs another VFS, such as one with an OPFS file per database, new databases can be created on it while existing ones keep working in the pool, without a migration.
Our pool VFS encrypts databases itself, in TypeScript, with Paul Miller's @awasm/noble. It provides synchronous WebAssembly ciphers and hashes without dependencies. Its noble backend, which wraps the audited @noble/ciphers and @noble/hashes, can replace the default one. Each worker that encrypts gets the two fixed-size WebAssembly memories of the default backend: 10 MiB for AES-CBC and 20 MiB for SHA-512.
The format is SQLCipher 4's as the sqlcipher scheme of SQLite3 Multiple Ciphers writes it. So the databases @evolu/web 3 encrypted with @evolu/sqlite-wasm 2.2.4 open unchanged, and 2.2.4 opens what we write. Every page, also in a rollback journal, is encrypted with AES-256-CBC and a random IV and authenticated with HMAC-SHA512. The key never reaches SQLite. src/SahPool.ts documents the format. @evolu/web 1.0.1-preview.6 to 2.4.0 encrypted databases with PRAGMA legacy = 4, SQLCipher 4's own format with page 1 encrypted from byte 16. Those databases have not opened since @evolu/web 3.0.0, and they don't open here either.
@evolu/web passed 2.2.4 the key as the SQL text x'<hex>', SQLCipher's notation for a raw key. SQLite3 Multiple Ciphers 2.2.4 treated it as a passphrase because of a bug, fixed in its 2.2.5. So the databases @evolu/web 3 encrypted with 2.2.4 open with the key derived from that text, as before. They are never rekeyed automatically. A database we create is encrypted with the key itself, which 2.2.4 opens only when given the key in its raw-key notation, raw:<hex>.
Only a database file and its journal are encrypted. 2.2.4 encrypted the files VACUUM INTO and ATTACH write with the database's key, but SQLite ignores the KEY clause of ATTACH and PRAGMA key, rekey and cipher. So here, VACUUM INTO and ATTACH write an unencrypted file unless a key is registered for its path with the pool's registerKey while they open it. A file that ATTACH creates can't be encrypted, because SQLite reserves no bytes in it. But VACUUM INTO can create an encrypted copy, which ATTACH then opens with its key registered. An encrypted database keeps secure_delete on, as 2.2.4 turned it on. Unlike 2.2.4, it refuses to turn it off, because a freed page SQLite doesn't write would fail to authenticate. Our tests compare the pool with 2.2.4 itself, in Node.js and in Chromium, Firefox and WebKit, and with an implementation of the format on node:crypto.
With encryption in the pool, the binary no longer contains SQLite3 Multiple Ciphers, which 2.2.4 encrypts with. It's plain SQLite: SQLite's own C from sqlite.org, compiled with SQLite's own makefile, plus Emscripten's output. There's no other C. In SQLite's full-featured build of 3.53.4, here is what leaving out SQLite3 Multiple Ciphers 2.5.1 gives:
- The C source the build compiles is 100,241 lines shorter. It's SQLite's 269,649-line
sqlite3.cinstead of the 369,890-line SQLite3 Multiple Ciphers amalgamation. About 72,500 of the extra lines were third-party code: its cipher schemes, their cryptography, and bundled libraries such as AEGIS, miniz and Argon2. The rest were SQLite's own code: a second copy ofsqlite3.h,sqlite3ext.h,tclsqlite.cand extensions fromext/misc. - Our encryption instead is 975 lines of TypeScript, 641 without comments and blank lines. In
src/SahPool.ts, it's 686 lines: the codec at the end of the file, 381 lines, and the parts of the VFS that call it. Insrc/Database.ts, it's 289 lines that declare what an encrypting VFS does, open encrypted connections and fall back to the key 2.2.4 derived. The ciphers and hashes come from@awasm/noble. - The wasm went from 1,068,015 to 868,886 bytes, and from 473,354 to 401,985 bytes with
gzip -9. The@awasm/noblemodules the pool encrypts with add 150,697 bytes of JavaScript minified by esbuild, and 51,843 withgzip -9. - The wasm exports 216 public C functions instead of 232. The 11
sqlite3mc_*functions are gone. So is the API of SQLite's encryption extension that SQLite3 Multiple Ciphers implemented:sqlite3_key,sqlite3_key_v2,sqlite3_rekey,sqlite3_rekey_v2andsqlite3_activate_see.
There's also less to trust. 2.2.4 repackaged a WebAssembly binary another project built, with that project's cipher code and bundled libraries inside. We build ours in CI from SQLite's source, pinned by the hash sqlite.org publishes, and the build reproduces the same bytes. The package's only runtime dependency, @awasm/noble, has no dependencies of its own. It comes from Paul Miller, whose @noble/ciphers, @noble/hashes and @scure/bip39 Evolu's other cryptography already uses, so it adds no new party to trust. We pin it to an exact version, because any release can regenerate its WebAssembly with a new version of its compiler. We upgrade it only on purpose, after our tests pass with the new binaries. The package also has a peer dependency, @evolu/common, Evolu's own, whose Result and Task its API returns. 2.2.4 had none. That leaves less code to audit and fewer places for a supply-chain attack to hide.
We don't ship that full-featured build, though. We ship SQLite's bare-bones configuration, chosen with the makefile's own variables. It comes with JSON, which Evolu queries, and the math functions, which apps can call through Evolu's query builder. It also keeps temporary files, such as sorts, temporary tables and statement journals, in memory. It omits WAL, the authorizer, the progress callback, incremental blob I/O and the introspection pragmas. Evolu's own SQL tests pass on it as they do on better-sqlite3. The C API covers only what we build. FTS5, R*Tree, the session extension, the preupdate hook, the column metadata functions sqlite3_column_database_name, sqlite3_column_table_name and sqlite3_column_origin_name, and the registration of virtual tables and window functions aren't there. If you need one of them, start a discussion. Compared with SQLite's full-featured build of 3.53.4 without SQLite3 Multiple Ciphers:
- Less C is compiled. Thirteen files of
sqlite3.c, 49,818 of its 269,649 lines, contribute nothing, among themfts5.c,sqlite3session.c,wal.candrtree.c. After the preprocessor, the C the build compiles has 87,381 non-blank lines instead of 119,158. - The wasm went from 868,886 to 649,755 bytes, from 401,985 to 298,537 with
gzip -9, and from 348,808 to 259,521 with Brotli at quality 11. With SQLite3 Multiple Ciphers, the full-featured build was 1,068,015 bytes, 473,354 withgzip -9. The stack overflow check we add, described below, made our 3.53.4 binary 669,403 bytes, 302,491 withgzip -9and 262,026 with Brotli. The 3.54.0 binary we ship now, which also exports the backup API, is 677,479 bytes, 305,733 withgzip -9and 265,111 with Brotli. - The wasm exports 156 public C functions instead of 216, and imports 32 functions instead of 35, besides its memory and the stack overflow check's handler. Since 3.54.0, the bare-bones build also exports the five functions of the backup API, so ours exports 161.
Evolu opens one connection per database and runs one operation at a time. There's no SQLITE_BUSY to handle and no interleaving to reason about. More connections in one worker wouldn't add parallelism anyway. They all run on the same JavaScript thread, and with a rollback journal, a reader still blocks another connection's commit. And because every call is synchronous, a connection that gets SQLITE_BUSY can't even wait for the other one to finish. It can only yield and retry, which is serializing with extra steps. We prefer this simple mental model, but we're open to revisiting it if a real use case appears. Feel free to start a discussion.
We watch upstream changes to SQLite's JavaScript and incorporate them after our own audit. Our loader also depends on the functions the WebAssembly imports and exports, and those come from the Emscripten version and SQLite's build settings. The generator pins the imports and exports with their wasm types, together with SQLite's constants and struct layouts. A check fails whenever a new build differs in any of them, so every toolchain upgrade gets the same review. The loader refuses a binary whose constants or export names differ from the pinned ones, or that needs an import it doesn't implement. Neither the check nor the loader compares code or build settings that change none of these, such as SQLITE_TEMP_STORE. A test pins those with PRAGMA compile_options.
The WebAssembly
We build the .wasm ourselves with scripts/build-wasm.mts. It's SQLite 3.54.0 from sqlite.org's source zip, unpatched, compiled by Emscripten 6.0.3 with SQLite's own makefile, in its bare-bones configuration with the options above, plus Emscripten's stack overflow check. The source is pinned by the SHA3-256 sqlite.org publishes. Emscripten is pinned by its version, and in CI by the image digest too. The output's sha256 covers the rest: the build fails unless it equals the pinned one and the wasm matches the generator's pins. Two builds in fresh work directories gave the same bytes. The SQLite Wasm job of the Checks workflow builds it in CI, in the official Emscripten image pinned by digest. When no input changed, the job restores the wasm from a cache instead. Either way, it hands the wasm to the jobs that test it. Built files are never committed.
The tests and playgrounds of the Evolu repository load wasm/sqlite3.wasm and fail when it's missing or isn't the pinned wasm. To get it without building it, run pnpm sqlite-wasm:download in the repository root. It downloads the published package that scripts/download-wasm.mts names and writes the package's wasm only if its sha256 is the pinned one. When the pin changes, no published package holds the new wasm until the next release, so the download says to build it. We set that release's version and sha256 in scripts/download-wasm.mts, sometimes before it's published, and the download then fails with HTTP 404 until it is. The examples load the wasm too, but they don't check it, because they're templates to copy.
To build or verify it, use an emsdk that has Emscripten 6.0.3 installed and activated. If the build succeeds, it has reproduced the pinned bytes:
EMSDK=/path/to/emsdk node scripts/build-wasm.mtsSQLite's makefile puts the 512 KiB C stack directly above SQLite's static data. In SQLite's own build, SQL that is legal but nests deeply, such as a chain of 600 triggers where SQLite allows 1,000, runs the stack into that data and overwrites it without an error. With 2.2.4, such an insert never returned. Built without the check, our binary was left failing with SQLITE_NOMEM, even to open another database. So we build with -sSTACK_OVERFLOW_CHECK=2. With it, every function compares the stack pointer it sets with the stack's limits, and our loader turns an overflow into a WebAssembly.RuntimeError, which breaks the instance as any trap does. The check adds 19,648 bytes, 3,954 with gzip -9. In Node.js 24 on an Apple M5, our micro benchmarks of run and exec on an in-memory database take 1% to 5% longer with it.
Bundlers
sqliteWasmUrl is new URL("../wasm/sqlite3.wasm", import.meta.url). Vite, webpack, Next.js and other bundlers recognize it. They emit the binary with the app and give the URL of their copy. Pass fetch(sqliteWasmUrl) to createSqliteWasm. It compiles the binary while it downloads if the server sends it as exactly application/wasm, without parameters, which is the type WebAssembly.compileStreaming requires. Otherwise, it compiles the binary from its bytes once it has downloaded.
Vite doesn't process the URL in a dependency it prebundles, so add @evolu/sqlite-wasm to optimizeDeps.exclude. Vite also emits the binary whenever the app imports @evolu/sqlite-wasm, even when the app loads its own binary instead, so a PWA that precaches *.wasm downloads it too. webpack emits it only when the app uses sqliteWasmUrl.
Versions
As in SQLite's own npm package, the version is the version of SQLite the package builds, followed by -build<n>. 3.53.4-build1 is our first build of SQLite 3.53.4, 3.53.4-build2 the next one, and a new SQLite release starts again at -build1. The build number counts our releases, and a release may also change the TypeScript API. Pin the exact version and read the release notes before upgrading. npm treats -build<n> as a prerelease, so a range such as ^3.53.4-build1 never reaches a build of another SQLite release. Semver compares build10 with build9 as text, so a tenth build would sort below the ninth. The repository's scripts/version-sqlite-wasm.mts refuses it, so a SQLite release gets at most nine builds, and a tenth needs a new SQLite pin.
Credits
- SQLite, public domain.
@awasm/nobleby Paul Miller, MIT License, which encrypts databases.- Emscripten, MIT License, which compiles the WebAssembly.
Documentation
For detailed information and usage examples, please visit evolu.dev.
Community
The Evolu community is on GitHub Discussions, where you can ask questions and voice ideas.
To chat with other community members, you can join the Evolu Discord.
