Torna al blog

2026-06-04

better-sqlite3 senza bloccare l'event loop: worker e Nitro

Pattern con worker thread per query SQLite sincrone in app Node/Nitro e cosa monitorare in produzione.

better-sqlite3 è sincrono per design: ogni prepare().run() o .all() occupa il thread JavaScript fino al ritorno della syscall SQLite. Under load, anche query “veloci” accumulano latency HTTP perché bloccano l’event loop di Node.

Docs ufficiali raccomandano operazioni pesanti in worker thread o processo separato. Nitro/Nuxt usano Node server preset—stesso problema del classico Express.

Perché è un problema misurabile

In Node single-threaded, mentre SQLite lavora, nessun altro I/O asincrono dello stesso processo avanza. Un burst di richieste che fanno SELECT contemporaneamente vede p95 salire anche se ogni query dura 2 ms.

Pattern: pool di worker minimi

Architettura:

HTTP handler → serializza job → Worker 1..N (ogni worker possiede una Database instance)

Ogni worker importa better-sqlite3 e apre un file DB (o :memory: per test). Comunicazione via parentPort.postMessage / workerData.

Pseudocodice master:

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 crea new Database(path, { readonly: true }) dove possibile per letture parallele sicure; per scritture serializza in un worker o usa busyTimeout e transazioni brevi (SQLite è single-writer).

Nitro: lifetime e path DB

  • In serverless puro, filesystem può essere read-only—SQLite persistente non sempre è sensato; in VPS o container con volume, funziona.
  • Warm instance: riusa worker creati all’avvio hook Nitro (nitro.hooks.ready o modulo singleton) invece di new Worker per richiesta.
  • Path DB: risolvilo assoluto una volta, passa via workerData.

Errori e teardown

Gestisci worker.on('error') e exit code ≠ 0 con restart controllato. Un worker morto senza recovery blocca metà throughput.

Metriche

  • Queue depth: richieste in attesa di worker libero.
  • Round-trip worker vs tempo query pura (serializzazione message-passing costa µs–ms).
  • Checkpoint WAL periodico se usi WAL mode su file grande.

WAL: un writer, molti reader

Con journal_mode=WAL, SQLite permette letture parallele mentre la scrittura procede in modo serializzato. Nella pratica better-sqlite3, i maintainer enfatizzano throughput ottimale con thread/caller dedicato alla scrittura e più connessioni read-only per le SELECT—pattern coerente con worker pool dove un solo worker gestisce mutazioni e gli altri servono query di lettura. Vedi discussioni ufficiali su concorrenza nel repo (README e note threading).

Senza WAL, locking rollback intralcia anche letture durante commit lunghi.

Alternative

  • libsql / Turso per HTTP remoto (modello diverso).
  • Async driver altrove—fuori scope di better-sqlite3.

Sintesi

better-sqlite3 è eccellente per DX sincrona; in server HTTP concorrente sposta il synchronous block fuori dall’event loop con worker dedicati e misura coda + fail worker. Il README better-sqlite3 spiega limiti multithreading nativi—leggerlo prima di fidarti di shared cached.

Vuoi applicare idee come queste al tuo prodotto?

Raccontaci contesto, vincoli e obiettivi: ti diciamo se ha senso lavorare insieme e come impostare il primo passo.