2026-06-04
better-sqlite3 without blocking the event loop: workers and Nitro
Worker-thread patterns for synchronous SQLite queries in Node/Nitro apps and what to monitor in production.
better-sqlite3 is synchronous by design: each prepare().run() or .all() holds the JavaScript thread until SQLite returns. Under load even “fast” queries add HTTP tail latency because they block Node’s event loop.
Upstream docs suggest pushing heavy work into worker threads or separate processes. Nitro/Nuxt on a Node server preset faces the same reality as Express.
Why it shows up in metrics
Node is single-threaded for JS: while SQLite runs, other concurrent async work stalls. A burst of requests issuing SELECT may push p95 even if each query is only ~2 ms.
Pattern: tiny worker pool
Architecture:
HTTP handler → serialize job → Worker 1..N (each owns a Database handle)
Each worker imports better-sqlite3 and opens one DB path (or :memory: in tests). Communicate with parentPort.postMessage / workerData.
Master sketch:
import { Worker } from 'node:worker_threads'
const workers = [new Worker('./sql-worker.mjs'), new Worker('./sql-worker.mjs')]
let rr = 0
export function queryAll(sql: string, params: unknown[]) {
const w = workers[rr++ % workers.length]!
return new Promise((resolve, reject) => {
w.once('message', (m) => (m.err ? reject(m.err) : resolve(m.rows)))
w.postMessage({ sql, params })
})
}
sql-worker.mjs should open new Database(path, { readonly: true }) for read-heavy paths; writes should funnel through one writer worker or rely on SQLite’s single-writer semantics with short transactions + busyTimeout.
Nitro: lifetime + DB pathing
- Pure serverless often has an ephemeral / read-only FS—persistent SQLite may be the wrong tool; VPS/containers with volumes work.
- Warm instances: reuse workers created during Nitro startup (
nitro.hooks.readyor a module singleton) instead of per-requestnew Worker. - Resolve the DB path once absolutely and pass via
workerData.
Errors and teardown
Handle worker.on('error') and non-zero exit codes with controlled restart. Half a dead worker pool means half throughput forever.
Metrics
- Queue depth waiting for idle workers.
- Worker round-trip vs raw SQL time (message passing is not free).
- WAL checkpoints for large append-heavy files.
WAL: one writer, many readers
With journal_mode=WAL, SQLite lets readers run while writes are serialized. better-sqlite3 guidance from upstream issues/README boils down to one dedicated writer plus read-only handles (often one per worker) for heavy SELECT traffic—exactly what you model when only one worker owns mutations. Read the README and docs/threads.md before sharing handles across workers.
Without WAL, rollback journals block readers during long commits.
Alternatives
- libsql / Turso over HTTP—different operational model.
- Other async-native drivers—out of scope for better-sqlite3 fans.
Summary
better-sqlite3 offers great synchronous DX; for concurrent HTTP servers move the blocking syscall off the event loop with dedicated workers and monitor queue depth + worker health. Read the upstream threading notes before expecting magic from shared caches.