What a Prisma transaction conflict taught me about concurrency, optimistic locking, and the limits of trusting your runtime.
The Bug That Shouldn’t Have Existed
I was writing a test for a feature that updates a user’s wallet balance. The logic was straightforward: read the current balance, subtract an amount, write it back. Standard stuff.
Then the test started failing intermittently with a Prisma error I hadn’t seen before:
PrismaClientKnownRequestError: Transaction failed due to a
write conflict or a deadlock. Please retry your transaction.
My first instinct? This must be a test setup issue. My second instinct? Wait — Node.js is single-threaded. How is there a conflict?
That question cracked open something I hadn’t fully thought through.
The Single-Threaded Illusion
Node.js is indeed single-threaded in the sense that your JavaScript runs on one thread — no two lines of your JS execute simultaneously. This makes it easy to reason about in-memory state without mutex locks or semaphores.
Your database doesn’t care about Node’s threading model.
PostgreSQL (and most relational databases) is a fully concurrent system. It handles multiple connections simultaneously, each with their own transaction scope. When your Node.js app makes two async calls to the database — even from the same process — those can arrive at Postgres as two independent concurrent transactions.
Consider this scenario:
// Two async operations running "concurrently" via event loop
const [result1, result2] = await Promise.all([
deductFromWallet(userId, 100),
deductFromWallet(userId, 50),
]);
To your Node code, this looks sequential in mental model. But both promises fire database queries at the same time. Both read the same wallet row. Both try to update it. That’s a race condition at the database layer — and Postgres sees it clearly even if Node doesn’t.
⚠ Common TrapPromise.all is concurrent at the DB layer. If you
Promise.alltwo operations that touch the same row, you have a concurrency problem even in Node.js.
What Is a Transaction Conflict?
A transaction conflict (also called a write conflict) happens when two or more database transactions try to read and write the same row, and their operations overlap in a way that would produce inconsistent data.
Postgres uses MVCC (Multi-Version Concurrency Control) to handle this. Each transaction sees a snapshot of the data at the time it started. When two transactions both modify the same row, the second one to commit realizes it was working with stale data — and Postgres throws the conflict error.
There are two classic strategies to handle this:
Strategy 1 — Pessimistic Concurrency Control
Lock the row before reading it. No one else can touch it until you’re done.
-- Lock the row immediately on read
SELECT * FROM wallets WHERE id = $1 FOR UPDATE;
In Prisma, this is done by running queries inside a $transaction with a SELECT FOR UPDATE raw query. Powerful — but locking blocks other connections, and under high load you can trade conflicts for contention.
Strategy 2 — Optimistic Concurrency Control (OCC)
Don’t lock anything. Attempt the update, but include a condition: “only update if the row hasn’t changed since I last read it.” If it has changed, the update fails and you retry.
The classic implementation uses a version field on the model:
// schema.prisma
model Wallet {
id String @id
balance Float
version Int @default(0)
}
const wallet = await prisma.wallet.findUnique({ where: { id } }); // a: read
const updated = await prisma.wallet.updateMany({
where: {
id: wallet.id,
version: wallet.version // guard clause
},
data: {
balance: wallet.balance - amount,
version: { increment: 1 },
},
}); //b: write
if (updated.count === 0) {
throw new Error("Conflict: wallet was modified by another transaction");
}
If the version changed between your read (a) and your write (b), updateMany matches zero rows — you know there was a conflict, and you can retry.
Why OCC Fits Node.js Better
Pessimistic locking is powerful but expensive. Locks block. Under high load, you trade concurrency conflicts for lock contention — database connections piling up, waiting for a row to release.
Optimistic concurrency control fits the Node.js async model better for a few reasons:
Most operations don’t actually conflict. If your users’ wallets are rarely touched simultaneously, locks are pure overhead. OCC costs nothing when there’s no conflict.
Retries are cheap in async code. A retry loop in an async function is ergonomic in JavaScript. With pessimistic locking, a blocked transaction holds a connection open — that’s a connection pool slot consumed for the entire duration of the wait.
It scales horizontally. If you ever run multiple Node.js instances (and you probably will), OCC works across all of them. It doesn’t matter where the conflicting write came from.
ℹ When pessimistic locking winsFor high-contention rows — shared inventory counts, flash-sale limits, shared queues — pessimistic locking can be more efficient. If conflicts are frequent, OCC retries add up fast.
Implementing a Retry Wrapper in Prisma
Prisma doesn’t ship built-in OCC with version fields, but it’s straightforward to implement. Here’s a reusable wrapper that handles retries with exponential backoff:
async function withOptimisticRetry(fn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const result = await fn();
if (result !== null) return result;
// null signals a version mismatch — retry
} catch (err) {
if (err.code === 'P2034') {
// Prisma's native transaction conflict code
if (attempt === maxRetries - 1) throw err;
} else {
throw err; // non-conflict errors bubble immediately
}
}
// Exponential backoff: 50ms, 100ms, 200ms...
await new Promise(r => setTimeout(r, 50 * Math.pow(2, attempt)));
}
throw new Error("Max retries exceeded — too many conflicts");
}
// usage:
await withOptimisticRetry(() => deductFromWallet(userId, amount));
For native Prisma transaction conflicts (which surface automatically when using serializable isolation), catching P2034 and retrying is the idiomatic fix.
What My Test Was Actually Exposing
Going back to the original failure — what was happening?
My test was simulating concurrent requests using Promise.all. Two deductions on the same wallet, at the same time. In production, this could happen if a user double-taps a payment button, or if two webhook events arrive within milliseconds of each other.
Node’s single-threaded event loop meant my JavaScript didn’t have a race. But my two async functions each opened database connections, and Postgres saw two transactions reading and writing the same row. Conflict.
The test wasn’t broken. It was revealing a real bug hiding behind the assumption that “single-threaded = no concurrency issues.”
Key Takeaways
- Single-threaded JavaScript ≠ single-threaded I/O. Every
await db.query()is a separate round-trip to a concurrent database. Multiple awaits in flight means multiple concurrent DB operations. Promise.allis concurrent at the DB layer. If you fire two operations on the same row simultaneously, you have a concurrency problem even in Node.js.- Optimistic Concurrency Control is a natural fit for async Node apps. Add a
versionfield, guard your updates, and retry on conflict. Lightweight, scalable, no held connections. - Write tests that simulate concurrent access. If a feature could ever be triggered twice in quick succession, test it with
Promise.all. It surfaces exactly these issues. - Know your Prisma error codes.
P2034means transaction conflict. Catch it, back off, and retry.
The mental model most Node.js developers carry — “I don’t need to worry about concurrency” — is true for in-memory state. It’s dangerously false for anything that touches a database. Your event loop is single-threaded. PostgreSQL is not.
The test failure was a gift. It forced me to understand where my system’s actual concurrency boundary was — not in the runtime, but in the rows.
Recommended books to learn about database concurrency:
- Designing Data-Intensive Application by Martin Kleppmann
- Concurrency Control and Recovery in Database Systems by Christos H. Papadimitriou
- Concurrency Control and Recovery in Database Systems by Philip A. Bernstein, Vassos Hadzilacos, Nathan Goodman
Also, check out the following:
Comments
Loading comments…