
The hardest part of any booking system isn't the calendar UI - it's making sure two people can never book the same slot. Here's how to prevent double bookings reliably in a Next.js app.
Most booking-system tutorials show you how to build a pretty calendar and save a reservation. Then they quietly skip the one problem that actually matters in production: what happens when two people try to book the same slot at the same time?
That's the part that breaks real apps - a double booking, an angry customer, and a bug you can't reproduce on your laptop. In this guide I'll show you how to prevent double bookings reliably in a Next.js booking system, from the naive mistake almost everyone makes to the database-level guarantees that actually hold up under pressure.
Why "check if it's free, then save" is a trap
The obvious approach looks like this: check whether the slot is taken, and if it's free, save the booking.
const existing = await prisma.booking.findFirst({
where: { resourceId, startTime },
});
if (existing) throw new Error("Slot taken");
await prisma.booking.create({ data: { resourceId, startTime } });This works perfectly until two requests arrive at almost the same moment. Both run the check, both see the slot as free, and both create a booking. This is a race condition, and it's invisible in testing because you're only one person clicking. In production, with real concurrent users, it will happen.
The fix isn't better application code - it's letting the database enforce the rule, because the database is the only place that can settle a tie atomically.
Fix 1: A unique constraint for fixed slots
If your bookings are fixed slots (e.g. a 9:00 AM appointment for a given resource), the simplest bulletproof guard is a unique constraint. Add it in Prisma:
model Booking {
id String @id @default(cuid())
resourceId String
startTime DateTime
createdAt DateTime @default(now())
@@unique([resourceId, startTime])
}Now the database physically cannot store two bookings for the same resource and time. Instead of checking first, just try to create and handle the failure:
try {
await prisma.booking.create({ data: { resourceId, startTime } });
} catch (e) {
// Prisma throws P2002 on a unique constraint violation
if (e.code === "P2002") {
return Response.json({ error: "That slot was just taken" }, { status: 409 });
}
throw e;
}Whoever commits first wins; the second request gets a clean 409 instead of a silent double booking. No race condition, because the uniqueness check and the insert are one atomic operation.
Fix 2: Preventing overlapping bookings (the hard case)
Unique constraints only work for identical start times. But most real systems have variable durations - a 60-minute booking from 2:00 shouldn't allow another from 2:30. A unique constraint can't catch that overlap.
This is where almost every tutorial stops, but PostgreSQL has a proper answer: an exclusion constraint on a time range. First enable the extension, then add the constraint (via a raw SQL migration, since Prisma doesn't model this directly):
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE "Booking"
ADD CONSTRAINT no_overlapping_bookings
EXCLUDE USING gist (
resource_id WITH =,
tstzrange(start_time, end_time) WITH &&
);In plain English: for the same resource, PostgreSQL will reject any booking whose time range overlaps (&&) an existing one. The database itself now guarantees no two bookings for the same resource can ever overlap - no matter how many requests hit it at once. Your app just tries to insert and handles the rejection, exactly like Fix 1.
Fix 3: When you need to do more than insert
Sometimes booking involves several steps - create the booking, decrement a credit, send a confirmation. Wrap the ones that must succeed or fail together in a transaction so a half-finished booking can't exist:
await prisma.$transaction(async (tx) => {
await tx.booking.create({ data: { resourceId, startTime, endTime } });
await tx.credit.update({
where: { userId },
data: { balance: { decrement: 1 } },
});
});Combined with the constraints above, the constraint prevents the double booking and the transaction keeps related changes consistent.
Don't forget timezones
A booking bug that only appears for overseas customers is almost always a timezone issue. Store every timestamp in UTC in the database, and convert to the user's local time only for display. Never store "2:00 PM" without a timezone - it's meaningless once a customer in another country books it.
Frequently asked questions
Why do double bookings happen even though I check availability first?
Because the check and the save are two separate steps. Two simultaneous requests can both pass the check before either one saves. The fix is to let the database enforce uniqueness atomically instead of checking in application code.
What's the simplest way to prevent double bookings?
For fixed time slots, a unique constraint on (resourceId, startTime) is the simplest reliable solution. Try to insert and handle the constraint error, rather than checking first.
How do I prevent overlapping bookings with different durations?
Use a PostgreSQL exclusion constraint with a time range (tstzrange) and the btree_gist extension. It rejects any booking whose range overlaps an existing one for the same resource.
Do I need Redis or a queue to handle booking concurrency?
Usually not. For most booking systems, database constraints and transactions are enough and far simpler. Queues and locks only become necessary at very high scale.
Building a booking or scheduling system and want it to be reliable under real traffic - not just in a demo? This is exactly the kind of production detail I handle. Tell me what you're building (https://osamahabib.com/contact) and I'll help you get it right.
Osama Habib
Multan, Pakistan
Full Stack Developer specialising in Next.js, Node.js, and the MERN stack. I write about modern web development, system design, and practical engineering.


