DEV Community

Akın Coşkun
Akın Coşkun

Posted on

I Solved Double-Booking Without Locks — Using One PostgreSQL Constraint

TL;DR

I built Randevu, a free appointment booking system for Turkish barbershops and hair salons. The UI was the easy part. The hard part was making sure two customers can never grab the same time slot, even when they both hit "confirm" in the same millisecond. Instead of application-level locks, a job queue, or "check-then-insert and hope," I pushed the guarantee down into the database itself with a single PostgreSQL EXCLUDE constraint. Here's why that works and everything else I had to build around it.

The problem every naive booking app has

Most booking flows look like this on the backend:

  1. Check if the slot is free (SELECT ... WHERE time = X)
  2. If free, insert the new appointment

That works fine in a demo. It breaks the moment two requests hit that code path close together. Both read "free" before either has written anything, both insert, and now you have two customers standing in the same chair at 2:00 PM. This is a textbook race condition, and "just add a loading spinner" does not fix it.

The usual fixes are:

  • Row-level locks, meaning SELECT with FOR UPDATE: works, but you have to remember to use it correctly on every code path that touches appointments, forever.
  • A job queue that serializes all writes: solves it, but now you've added an entire piece of infrastructure just to stop double-booking.
  • A unique constraint on the exact timestamp: only works if every appointment has an identical, fixed duration. The moment services have different lengths, this falls apart, because two ranges can overlap without their start times ever matching.

What actually works: EXCLUDE with a range type

Postgres has a constraint type built almost exactly for this: EXCLUDE USING gist. Instead of saying "these two rows can't have the same value," it says "these two rows can't overlap," using a range type (tstzrange) and the btree_gist extension.

Conceptually, the constraint on the appointments table says: for a given barber, no two rows may have overlapping start_time to end_time ranges. It doesn't matter if the request comes from the API, a background job, or a manual database edit, Postgres itself refuses the insert if it overlaps. No application code has to remember to check anything, because it is physically impossible to violate.

The practical effect: when two customers submit a booking for overlapping times at the same instant, one INSERT succeeds and the other fails with a constraint violation, which the API just turns into a normal "sorry, that slot was just taken" response. No locks to hold, no queue to babysit, no race condition, ever.

Appointment timeouts had to be dynamic, not fixed

A "held" appointment (customer started checkout but hasn't confirmed) needs to expire so it doesn't block the slot forever. A flat "expires in 10 minutes" rule sounds simple until you notice it breaks at closing time: if a shop closes at 8 PM and someone starts booking at 7:55, a fixed timeout can quietly hold a slot past close, or expire mid-conversation. So the hold duration is calculated relative to the barber's actual working hours for that day, not a constant, short enough near closing time, normal otherwise.

WhatsApp notifications without the WhatsApp Business API

The official WhatsApp Business API is built for companies with support teams and approval budgets, not a single barber. Randevu sends confirmations and reminders as automated messages over regular WhatsApp instead of requiring the shop to apply for, pay for, and integrate the Business API. The customer gets a normal WhatsApp message; the barber never has to touch a Meta developer console.

Push notifications without Firebase

Web push almost always means "add Firebase Cloud Messaging." Randevu skips it and talks to the browser's Push API directly with VAPID keys: no Firebase project, no extra vendor, one less thing to configure per environment.

Keeping the public booking link bot-free

A booking page has no login wall by design, that's the whole point, a customer shouldn't need an account. But an open, unauthenticated form is exactly what booking-spam bots look for. Cloudflare Turnstile sits in front of the confirm step as an invisible, non-annoying check, so real customers never see a CAPTCHA, but scripted submissions get filtered out before they ever reach the database.

The stack

  • Next.js 16 and TypeScript, full-stack, no separate backend
  • Prisma and PostgreSQL on Neon, with the EXCLUDE constraint enforced at the database level
  • Upstash Redis and QStash for scheduling reminders and expiring holds
  • Web Push (VAPID) for notifications
  • Cloudflare Turnstile for bot protection on the public booking form

Try it

Randevu is free for barbershops and hair salons, no app to install, no monthly fee. Live at randevu-five.vercel.app, source on GitHub: akincskn/randevu.

I'm Akın Coşkun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.

Top comments (0)