TLDR:
better-sqlite3can't open a bundled SQLite file from Vercel's/var/tasklayer even whenexistsSyncreturnstrue. Copy it to/tmpon cold start. Done.
The Setup
I was deploying an internal RAG chat app so our team could ask natural-language questions against a corpus of Loom, Zoom, and Make.com automation transcripts.
The stack: Next.js on Vercel, better-sqlite3 (a fast native Node.js SQLite binding), sqlite-vec (a vector-search extension for similarity lookups), and a pre-built rag.db committed into the repo and bundled into the serverless function.
Local: perfect. Vercel: immediate 500.
The Walls I Hit First (Plural)
I'll be honest — I thought I had ONE problem. I had four, stacked in sequence. Each fix revealed the next one.
- WAL pragma on a read-only mount —
better-sqlite3defaults to writing a sibling-walfile even when you open the DB readonly. Vercel's function layer is read-only. Skip the pragma when opening readonly. - Platform binary missing from the lockfile —
sqlite-vecships platform binaries as optional deps.npm installon Mac only locksdarwin-arm64. Vercel runslinux-x64,npm cisees no entry, installs nothing. Fix:npm install sqlite-vec-linux-x64@<exact-version> --save-exact --force. - File tracer exclusion — Next.js won't bundle files it never
imports. AddedoutputFileTracingIncludesinnext.config.tsto forcedata/rag.dbinto the function.
Three fixes in. existsSync('/var/task/.../rag.db') was returning true.
I thought I was finally done.
The REAL Wall
new Database(DB_PATH, { readonly: true }) threw SQLITE_CANTOPEN.
The file. Was. THERE.
I still don't fully know why — something about the overlay filesystem Vercel uses to mount /var/task. Exact cause: unclear. Symptom: completely reliable. And once I understood the pattern, the fix was obvious.
The Fix That Worked
Copy the database to /tmp on cold start.
const TMP_DB = '/tmp/rag.db';
function ensureRuntimeDb(): string {
if (existsSync(TMP_DB)) return TMP_DB;
if (!existsSync(DB_PATH)) {
throw new Error(`bundle missing rag.db at ${DB_PATH} (cwd=${process.cwd()})`);
}
copyFileSync(DB_PATH, TMP_DB);
return TMP_DB;
}
/tmp is the only reliably writable path in a Vercel serverless function (~512 MB available). Once the file is there, better-sqlite3 opens it without complaint.
Cold-start cost: ~50–200ms for a 22 MB database. Warm requests skip the copy entirely — /tmp persists for the function's warm lifetime (roughly 5–15 minutes of idle).
Why This Matters to Me
The lesson generalizes beyond SQLite.
If you're shipping a serverless function that uses a native binding — better-sqlite3, sharp, embedded model weights, anything with a .node addon — and the file exists in your bundle but the library still won't open it: /tmp is almost certainly the answer.
/var/task has enough quirks that native open() syscalls sometimes just fail against it. /tmp is plain, writable, boring — and it WORKS.
What I should have had going in is a checklist. I have one now.
P.S. Log the
/tmpcopy result explicitly during cold start. When something breaks at Layer 4, the log line"copied rag.db to /tmp (22 MB)"is the difference between a 2-minute debug and a 2-hour one.