TLDR: better-sqlite3 can't open a bundled SQLite file from Vercel's /var/task layer even when existsSync returns true. Copy it to /tmp on 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.

  1. WAL pragma on a read-only mountbetter-sqlite3 defaults to writing a sibling -wal file even when you open the DB readonly. Vercel's function layer is read-only. Skip the pragma when opening readonly.
  2. Platform binary missing from the lockfilesqlite-vec ships platform binaries as optional deps. npm install on Mac only locks darwin-arm64. Vercel runs linux-x64, npm ci sees no entry, installs nothing. Fix: npm install sqlite-vec-linux-x64@<exact-version> --save-exact --force.
  3. File tracer exclusion — Next.js won't bundle files it never imports. Added outputFileTracingIncludes in next.config.ts to force data/rag.db into 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 bindingbetter-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 /tmp copy 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.