Blog / / 5 min read

Deleting better-sqlite3, and What It Cost

An engineering note from building 0sec's engine: the persistence layer was migrated from better-sqlite3 to a pure-WASM SQLite implementation. Here's what broke, what was kept, and why dropping the native module made the engine run identically on every Node.js version.

Every now and then our engine would fail to start with the same error message:

Error: The module '/.../node_modules/better-sqlite3/build/Release/better_sqlite3.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION X. This version of Node.js requires
NODE_MODULE_VERSION Y. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

This is the standard story of native modules in the npm ecosystem. better-sqlite3 ships precompiled .node binaries per Node ABI, and prebuild-install walks a static table to pick the right one. Occasionally, on a very new Node.js release, the table is stale and prebuild-install picks the wrong binary. The install completes “successfully,” the lockfile is happy, the engine launches — and then the first call into SQLite explodes at runtime with the message above. For a tool that has to run reliably across every machine and runtime we deploy on, this is a credibility-killing first-run experience.

Two patches were attempted. First, the silent try { ... } catch { return null } around the database initializer was rewritten into a thrown error with a clear remediation hint, so the user could at least see what was wrong. Then a postinstall script was shipped that re-resolved the native binary against the current Node ABI and swapped it in if it was wrong. Both worked. Neither was the right fix.

The right fix is to not have a native module at all.

The Migration

We replaced better-sqlite3 with node-sqlite3-wasm — a pure-WebAssembly SQLite build that ships one .wasm file and runs identically on every Node.js version, on Bun, on Deno, in Electron, anywhere V8 and WASM exist. No ABI. No prebuilds. No postinstall. No rebuild dance. One binary, every runtime.

The catch: drizzle-orm — the engine’s query builder — speaks the better-sqlite3 API shape, not node-sqlite3-wasm’s. Drizzle’s BetterSQLiteSession class assumes the sync prepare(sql).all() / .run() / .get() interface, expects bound parameters in a specific form, and imports the native driver eagerly through drizzle-orm/better-sqlite3/driver.js. Ripping out better-sqlite3 naively would have broken every query in the engine.

So a thin shim was built:

// packages/db/src/wasm-shim.ts (excerpt)

import { Database as WasmDatabase } from "node-sqlite3-wasm";
// import BetterSQLiteSession from the deep `/session` subpath to avoid
// pulling in `drizzle-orm/better-sqlite3/driver.js`, which would
// import `better-sqlite3` at module load and defeat the whole point.
import { BetterSQLiteSession } from "drizzle-orm/better-sqlite3/session";

class ShimmedStatement {
  constructor(private impl: WasmStatement, private pluck = false) {}
  run(...args: unknown[]) { /* … translates args + delegates */ }
  get(...args: unknown[]) { /* … translates args + delegates */ }
  all(...args: unknown[]) { /* … translates args + delegates */ }
  // …
}

export function createShimmedDatabase(path: string): ShimmedDatabase { … }
export function createDrizzleFromShim<TSchema>(
  client: ShimmedDatabase,
  config: { schema: TSchema },
) { … }

240 lines of TypeScript. It presents enough of the better-sqlite3 surface that Drizzle cannot tell the difference. The rest of the engine — every query, every migration, every test — kept working unchanged.

What It Cost

Three things had to give:

  1. The WAL pragma. better-sqlite3 defaults to write-ahead logging mode for performance. node-sqlite3-wasm’s VFS implementation does not support WAL. The engine’s database is small (a few hundred KB at the high end, mostly findings and scan history), so the rollback-journal default is fine. The PRAGMA journal_mode = WAL line was dropped.

  2. Postinstall complexity. The scripts/verify-native.mjs workaround for the ABI mismatch is gone. Nothing to install, nothing to verify, nothing to swap. The tarball contains the .wasm file and that is it.

  3. A fresh install-and-run test on every supported runtime. The full path now runs under Node.js 18, 20, 22, 25, and Bun on every build. It is a one-line change to the CI matrix and it is worth the seconds.

What did not have to give: speed. node-sqlite3-wasm’s performance on the kind of workload the engine runs (a few hundred small inserts and selects per scan, never anything resembling a hot loop) is indistinguishable from the native module on a modern machine. The round-trip difference was below the noise floor of the test harness.

What It Unlocks

Two things, none of them obvious until you hold them in your hand:

  1. The engine runs on every Node.js version, including unreleased ones. When Node.js 26 ships, the engine will work on it without any additional action. This is the actual point. It runs the same under Bun as well, with a roughly 10x faster cold start as a free benefit — no code change required, since Bun’s resolver is faster and its package cache is smarter.

  2. The entire class of “installed and crashed before typing anything” first-run failures is gone. That class of bug was small in absolute count but enormous in damage — it killed the credibility of the tool the first time you hit it, and it was hard to reproduce because it depended on a specific Node ABI mismatch. The class is now empty.

The Meta-Lesson

Most of the patches shipped before this migration were addressing symptoms. The silent null cast → clear error. The postinstall ABI fixer. The helpful retry hint. Each was a real improvement and each took real engineering time. Cumulatively they cost more than the migration itself.

When you find yourself patching the same root cause for the third time, the right move is to delete the root cause. In this case the root cause was a class of dependency — native modules — that the engine did not actually need. Once that was accepted, the fix was a 240-line shim and a dependency swap.

0sec’s engine is a security tool. Its job is to find vulnerabilities and write reports, not to wrestle with prebuild-install. Every line of build infrastructure we do not own is a line we do not have to maintain.