Next.js
9 minutes readPreventing Race Conditions in a Next.js Store with Prisma and PostgreSQL
Explore atomic stock updates, database constraints, transactions, idempotency, and payment callbacks through a realistic checkout flow.

A checkout flow can look correct in a single-user test and still oversell inventory under concurrent requests. The key is to move critical invariants into atomic database operations and constraints, instead of relying on a read-then-write sequence in application code.
A checkout flow can look correct in a single-user test and still oversell inventory under concurrent requests. The key is to move critical invariants into atomic database operations and constraints, instead of relying on a read-then-write sequence in application code.
Understand the race condition
Imagine the last item has stock = 1. Two requests both read the value, both conclude that one item is available, and both attempt to create an order. The race exists because the read and decrement are separate operations without a shared concurrency guarantee.
Make the update atomic
import { Prisma } from '@prisma/client';
export async function reserveStock(productId: string, quantity: number) {
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Invalid quantity');
return prisma.$transaction(async (tx) => {
const result = await tx.product.updateMany({
where: { id: productId, stock: { gte: quantity } },
data: { stock: { decrement: quantity } },
});
if (result.count !== 1) throw new Error('Insufficient stock');
return tx.order.create({
data: { status: 'PENDING', items: { create: { productId, quantity } } },
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}Use a conditional atomic update: decrement only when stock is still greater than or equal to the requested quantity. Check the affected row count; zero means the reservation failed. Wrap the stock reservation and order creation in one transaction so an error rolls both back.
Isolation and retries
Serializable isolation can detect conflicting transactions, but it may abort one transaction. Production code must handle serialization failures with a bounded retry policy. The right isolation level depends on the data model and database; an atomic conditional update plus constraints may be sufficient for simpler inventory rules.
Keep payment outside long transactions
Do not hold a database transaction open while waiting for a remote payment provider. Create a pending order, initiate payment, then verify the provider callback on the server. Treat callbacks as untrusted input until signature, amount, currency, and order reference are checked.
Make repeated callbacks safe
export async function confirmPayment(providerEventId: string, orderId: string) {
return prisma.$transaction(async (tx) => {
// providerEventId should have a UNIQUE constraint in the database.
const event = await tx.paymentEvent.create({
data: { providerEventId, orderId },
});
await tx.order.update({
where: { id: event.orderId },
data: { status: 'PAID' },
});
});
}Payment providers may retry callbacks. Store a unique provider event ID or idempotency key and make repeated processing safe. A unique constraint is stronger than “check then insert” because two concurrent requests can pass the check at the same time.
Plan for partial failures
Define what happens when payment succeeds but your server times out before recording it, or when payment fails after stock was reserved. A robust design may use reservation expiry, a reconciliation job, and explicit state transitions such as PENDING, PAID, FAILED, and CANCELLED.
Test the invariants
Test two simultaneous purchases for the final unit, duplicate callbacks, provider timeout, transaction retry, and order creation failure. Assert database invariants after each test—not just the HTTP status returned to the browser.