SQLite Returns SQLITE_BUSY During Concurrent Writes in Drizzle ORM
Two background jobs writing at the same time turned a reliable SQLite database into a source of intermittent failures. The database was behaving exactly as documented, and my connection setup was quietly asking for trouble.
Benjamin Fazli
Principal EngineerSkopje, North Macedonia

The symptom
A small service wrote job results into SQLite through Drizzle. Under light use it was flawless. As soon as two workers ran in parallel, roughly one write in twenty failed with SQLITE_BUSY: database is locked, and the retry that followed usually succeeded. Intermittent, load dependent, and absent from every local test.
What SQLITE_BUSY actually means
SQLite allows many concurrent readers but only one writer. When a second connection tries to take the write lock while the first still holds it, the second does not queue by default. It gives up immediately and returns SQLITE_BUSY.
In the default rollback journal mode the situation is worse than most people expect: a write also blocks readers for the duration of the transaction. So a long read can starve a write, and a write can starve a read, and both surface as the same unhelpful error.
The fix, in three parts
Turn on write ahead logging. In WAL mode readers no longer block the writer and the writer no longer blocks readers. This alone removed most of my failures:
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
const sqlite = new Database('data/app.db')
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('busy_timeout = 5000')
sqlite.pragma('synchronous = NORMAL')
export const db = drizzle(sqlite)Set a busy timeout. With busy_timeout the driver waits and retries internally instead of failing on first contention. Five seconds is generous for short transactions and still fails fast enough to be visible when something is genuinely stuck.
Keep transactions short. This is the part no pragma fixes. My job handler opened a transaction, called an external API inside it, then wrote the result. The write lock was held for the entire duration of a network request. Doing the slow work first and the transaction last mattered more than either setting:
const result = await fetchExternalResult(job)
db.transaction((tx) => {
tx.insert(results).values({ jobId: job.id, payload: result }).run()
tx.update(jobs).set({ status: 'done' }).where(eq(jobs.id, job.id)).run()
})What I would not do
- Wrapping every query in a retry loop. It hides contention rather than removing it, and it turns a five millisecond write into an unpredictable one.
- Opening a new connection per query to dodge the lock. Every connection competes for the same single writer, so this makes contention worse while looking like isolation.
SQLite is a genuinely good production database for a single machine with modest write volume. It stops being a good fit the moment you need several processes writing constantly. Recognising that boundary early is cheaper than fighting it.
