Skip to content

Fixing D1 SQLITE_BUSY errors from concurrent writes

Covers SQLITE_BUSY caused by two or more write requests colliding on the same D1 database (including a Worker request racing a Cron Trigger), and by a single invocation issuing many unbatched writes in a row. Does not cover SQLITE_BUSY from an actually-stuck transaction left open by a crashed process, or D1's separate CPU-time/memory-limit resets — those return different, more specific error messages.

Unverifiedno reproductions yetWhy this confidence?
Revision 1published by DevYou curationrevision history
Run the diagnosisEvidence and compatibility

Symptoms

The diagnostic path

8 steps. Every step is written out below in full — the interactive version simply follows the branches for you.

  1. Step 1 · StartD1 write throws SQLITE_BUSY

    A write against D1 (INSERT/UPDATE/DELETE via db.prepare(...).run() or db.batch(...)) throws an error containing SQLITE_BUSY or "database is locked".

    What happens next

    • passed step 2, Check whether two writes are landing at the same moment
  2. Step 2 · TestCheck whether two writes are landing at the same moment

    D1's SQLite storage backend allows only one write transaction to commit at a time per database. Tail your Worker's logs and see whether the failing write overlaps in time with another write to the same database — a second user request, a Cron Trigger, or a queue consumer are the usual culprits.

    Read-onlysh
    npx wrangler tail --format pretty

    Expected result

    Two fetch invocations with near-identical timestamps, the second one ending in the D1_ERROR: database is locked: SQLITE_BUSY line.

    What happens next

    • passed step 3, Retry the write with backoff
    • failed step 4, Check whether one invocation issues many separate write statements in a loop
    • unknown step 3, Retry the write with backoff
  3. Step 3 · FixRetry the write with backoff

    This is expected SQLite behaviour, not a bug: when two write transactions collide, one of them has to lose. D1's own docs recommend retrying transient errors — SQLITE_BUSY included — with a bounded exponential backoff rather than surfacing the failure to the caller.

    Changes statets

    Changes system or service state. Review before running.

    Adds retry-with-backoff around the existing write. It does not change what data is written — only how many times a transient SQLITE_BUSY is retried (up to 5 attempts here) before the error is allowed through to the caller.

    async function runWithRetry(fn, maxAttempts = 5) {
      for (let attempt = 1; ; attempt++) {
        try {
          return await fn();
        } catch (err) {
          const retryable = String(err).includes("SQLITE_BUSY") || String(err).includes("database is locked");
          if (!retryable || attempt >= maxAttempts) throw err;
          const delayMs = 2 ** attempt * 20 + Math.random() * 50;
          await new Promise((r) => setTimeout(r, delayMs));
        }
      }
    }
    
    // await runWithRetry(() => env.DB.prepare("UPDATE accounts SET balance = ? WHERE id = ?").bind(newBalance, id).run());

    What happens next

    • passed step 5, Re-run under the same concurrent load
  4. Step 4 · TestCheck whether one invocation issues many separate write statements in a loop

    Look at the failing code path for a loop that calls .prepare(...).bind(...).run() once per row, instead of combining the writes into a single db.batch([...]) call. Each separate call is its own round trip, which widens the window in which another invocation's write can collide with yours.

    Read-onlysh
    grep -n "\.prepare(" src/**/*.ts

    Expected result

    Multiple `.prepare(...)` calls inside a `for`/`.map()` loop rather than a single `db.batch([...])` call.

    What happens next

    • passed step 6, Combine the loop of writes into one db.batch() call
    • failed step 7, Not a write-write collision — escalate
    • unknown step 7, Not a write-write collision — escalate
  5. Step 5 · Verify the fixRe-run under the same concurrent load

    Reproduce whatever originally triggered the error — two parallel requests, or a manual Cron Trigger invocation alongside a normal request — and confirm SQLITE_BUSY no longer reaches the client.

    Read-onlysh
    curl -s https://your-worker.example.workers.dev/write & curl -s https://your-worker.example.workers.dev/write & wait

    Expected result

    Both requests return a normal 200 response; neither surfaces a D1_ERROR.

    What happens next

    • passed step 8, Resolved
    • failed step 7, Not a write-write collision — escalate
    • unknown step 7, Not a write-write collision — escalate
  6. Step 6 · FixCombine the loop of writes into one db.batch() call

    D1's batch() method sends every statement in one request and D1 runs them sequentially inside a single transaction. That drops N separate write round trips down to one, shrinking the collision window with any other writer.

    Changes statets

    Changes system or service state. Review before running.

    Executes the same writes as a single D1 batch transaction instead of N sequential ones. The resulting data is unchanged; only the number of separate write transactions drops from N to 1.

    const statements = rows.map((row) =>
      env.DB.prepare("UPDATE items SET qty = ? WHERE id = ?").bind(row.qty, row.id)
    );
    await env.DB.batch(statements);

    What happens next

    • passed step 5, Re-run under the same concurrent load
  7. Step 7 · EndNot a write-write collision — escalate

    If neither retrying nor batching resolves it, this may be a transaction left open by a previous failed request, or two different Workers/bindings pointed at the same databaseid and racing each other in a way retries can't smooth over. Check `wrangler d1 info <DATABASENAME>` for anything unexpected, and if it persists outside of load testing, file it with Cloudflare support — this is outside what SQLITE_BUSY retry logic can fix.

  8. Step 8 · EndResolved

    The write path now retries transient contention and/or batches related writes, so occasional overlap no longer surfaces as an error to the caller.

Sources

Why this confidence?

What would strengthen it: 6 more independent reproductions. Reproductions from 3 more distinct environments.