Mastering Database Transactions in Prisma: Avoiding Race Conditions

Imagine you are building a ticket booking platform or an e-commerce checkout. Two users concurrently click "Book Ticket" for the very last seat available. Without proper database transaction management, both checkouts might succeed, leading to a double-booking system failure. This is a classic race condition.
The Problem: Non-Transactional Concurrency
Consider the following naive code in a Next.js or Express handler:
const seat = await prisma.seat.findUnique({ where: { id } });
if (seat.isBooked) {
throw new Error("Seat already taken!");
}
await prisma.seat.update({
where: { id },
data: { isBooked: true, userId }
});
If two requests reach the server simultaneously, both might execute the findUnique read query before either executes the update write query. Both read requests see that the seat is free, and both attempt to overwrite it, resulting in data inconsistency.
The Solution: Interactive Transactions
Prisma provides a robust mechanism called Interactive Transactions using the $transaction helper. This groups your operations into a single ACID transaction block, ensuring that if any operation fails or if parallel changes violate constraints, the entire transaction rolls back.
await prisma.$transaction(async (tx) => {
const seat = await tx.seat.findUnique({
where: { id }
});
if (seat.isBooked) {
throw new Error("Seat already taken!");
}
return await tx.seat.update({
where: { id },
data: { isBooked: true, userId }
});
});
In high-concurrency settings, you can go further by implementing pessimistic locking or database constraints. However, Prisma's interactive transactions provide the baseline safety net that every professional software architect needs to understand.
