# Deleting better-sqlite3, and What It Cost

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

Published: 2026-04-08

Canonical article: https://0.security/blog/2026-04-07-deleting-better-sqlite3/

Our engine would fail to start with this error:

```
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 native-module story in npm. `better-sqlite3` ships precompiled `.node` binaries per Node ABI, and `prebuild-install` walks a static table to pick the right one. 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. 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.

We tried two patches. First, we rewrote the silent `try { ... } catch { return null }` around the database initializer into a thrown error with a clear remediation hint, so the user could at least see what was wrong. Then we shipped a postinstall script 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 we built a thin shim:

```ts
// 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 kept working unchanged: every query, every migration, every test.

## 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. We dropped the `PRAGMA journal_mode = WAL` line.

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, neither obvious until you hold it 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 with no additional action. This is the actual point. It runs the same under Bun, with a roughly 10x faster cold start as a free benefit and no code change required, because 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 large 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 we shipped before this migration addressed symptoms. The silent `null` cast became a clear error. The postinstall ABI fixer. The helpful retry hint. Each was a real improvement and each took real engineering time. Together they cost more than the migration itself.

**When you find yourself patching the same root cause for the third time, delete the root cause.** Here the root cause was a class of dependency, native modules, that the engine did not actually need. Once we accepted that, 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.
