<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Yassine Khattari</title>
    <description>The latest articles on DEV Community by Yassine Khattari (@yassine_khattari_3cb9d27d).</description>
    <link>https://dev.to/yassine_khattari_3cb9d27d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4232329%2Fe80b3d10-e222-42f1-a44b-9e360b6b74c7.png</url>
      <title>DEV Community: Yassine Khattari</title>
      <link>https://dev.to/yassine_khattari_3cb9d27d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yassine_khattari_3cb9d27d"/>
    <language>en</language>
    <item>
      <title>How I prevent double bookings in Postgres (advisory locks + Supabase)</title>
      <dc:creator>Yassine Khattari</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:15:13 +0000</pubDate>
      <link>https://dev.to/yassine_khattari_3cb9d27d/how-i-prevent-double-bookings-in-postgres-advisory-locks-supabase-11dl</link>
      <guid>https://dev.to/yassine_khattari_3cb9d27d/how-i-prevent-double-bookings-in-postgres-advisory-locks-supabase-11dl</guid>
      <description>&lt;p&gt;I built a booking app for gyms with React, TanStack Start and Supabase. The part that looked easy and wasn't: two people booking the same slot at the same moment.&lt;/p&gt;

&lt;p&gt;This post explains the approach I ended up with, with a simplified version of the code.&lt;/p&gt;

&lt;p&gt;The problem&lt;/p&gt;

&lt;p&gt;A client opens the booking page, picks a service, a date and a time slot. Two clients can do this for the same trainer and the same slot within milliseconds of each other.&lt;/p&gt;

&lt;p&gt;If your app does this in two steps:&lt;/p&gt;

&lt;p&gt;SELECT to check the slot is free&lt;br&gt;
INSERT the booking&lt;/p&gt;

&lt;p&gt;then both requests can pass step 1 before either one reaches step 2. Result: a double booking.&lt;/p&gt;

&lt;p&gt;Checking in the frontend or in your API code doesn't fix it, because the race happens between your two queries.&lt;/p&gt;

&lt;p&gt;Why not just a unique constraint?&lt;/p&gt;

&lt;p&gt;A unique constraint works if every booking uses exactly the same start time. It breaks down quickly:&lt;/p&gt;

&lt;p&gt;Services have different durations (30 min, 60 min), so bookings overlap without being equal.&lt;br&gt;
Group classes allow several people in the same slot, up to a capacity.&lt;/p&gt;

&lt;p&gt;An exclusion constraint can handle simple overlaps, but group capacity ("up to 10 people") doesn't fit that model nicely.&lt;/p&gt;

&lt;p&gt;The approach: lock, then check, inside the database&lt;/p&gt;

&lt;p&gt;I put the check in a BEFORE INSERT trigger on the bookings table, and I take a transaction-level advisory lock first. The lock key is the staff member plus the date:&lt;/p&gt;

&lt;p&gt;sql&lt;br&gt;
-- Simplified. The real version also handles group capacity and expired holds.&lt;br&gt;
CREATE OR REPLACE FUNCTION check_booking_availability()&lt;br&gt;
RETURNS trigger&lt;br&gt;
LANGUAGE plpgsql&lt;br&gt;
AS $$&lt;br&gt;
DECLARE&lt;br&gt;
  overlapping integer;&lt;br&gt;
BEGIN&lt;br&gt;
  -- Serialize concurrent bookings for the same staff member on the same day.&lt;br&gt;
  PERFORM pg_advisory_xact_lock(&lt;br&gt;
    hashtextextended(NEW.staff_id::text || NEW.booking_date::text, 0)&lt;br&gt;
  );&lt;/p&gt;

&lt;p&gt;SELECT count(*) INTO overlapping&lt;br&gt;
  FROM bookings b&lt;br&gt;
  WHERE b.staff_id = NEW.staff_id&lt;br&gt;
    AND b.booking_date = NEW.booking_date&lt;br&gt;
    AND b.status IN ('pending', 'confirmed')&lt;br&gt;
    AND b.start_time &amp;lt; NEW.end_time&lt;br&gt;
    AND b.end_time &amp;gt; NEW.start_time;&lt;/p&gt;

&lt;p&gt;IF overlapping &amp;gt; 0 THEN&lt;br&gt;
    RAISE EXCEPTION 'This time slot is no longer available';&lt;br&gt;
  END IF;&lt;/p&gt;

&lt;p&gt;RETURN NEW;&lt;br&gt;
END;&lt;br&gt;
$$;&lt;/p&gt;

&lt;p&gt;CREATE TRIGGER bookings_check_availability&lt;br&gt;
BEFORE INSERT ON bookings&lt;br&gt;
FOR EACH ROW EXECUTE FUNCTION check_booking_availability();&lt;/p&gt;

&lt;p&gt;What this gives you:&lt;/p&gt;

&lt;p&gt;The advisory lock is held until the transaction ends. A second booking for the same staff member and day waits for the first to commit or roll back, then runs its own check and sees the first booking.&lt;br&gt;
Different staff members, or different days, use different lock keys, so they don't block each other.&lt;br&gt;
The rule lives in the database, so it applies no matter which client inserts (your API, an admin script, a future mobile app).&lt;/p&gt;

&lt;p&gt;In the app, when the trigger raises the exception, the booking request fails and the client sees a message asking them to pick another slot.&lt;/p&gt;

&lt;p&gt;Group classes and capacity&lt;/p&gt;

&lt;p&gt;For group classes, the check changes from "any overlap blocks" to "count the people already in this class and compare with capacity". Same lock, same trigger, different condition.&lt;/p&gt;

&lt;p&gt;Temporary holds for paid bookings&lt;/p&gt;

&lt;p&gt;If a service requires payment, I create the booking as pending with a hold_expires_at timestamp while the client pays. The availability check ignores pending bookings whose hold has expired, so an abandoned checkout doesn't block a slot forever:&lt;/p&gt;

&lt;p&gt;sql&lt;br&gt;
AND NOT (b.status = 'pending'&lt;br&gt;
         AND b.hold_expires_at IS NOT NULL&lt;br&gt;
         AND b.hold_expires_at &amp;lt; now())&lt;br&gt;
Things I'd warn you about&lt;br&gt;
Test the race, don't assume it. Fire two inserts for the same slot in parallel (two terminals, or a small script) and confirm only one succeeds.&lt;br&gt;
Advisory locks are application-defined. Nothing stops other code from inserting without going through the trigger path, so keep the check in the trigger and not in the app only.&lt;br&gt;
Time zones. Store timestamps in UTC and convert per business. It's easy to get the "day" wrong near midnight.&lt;br&gt;
Row Level Security still matters. The trigger prevents double bookings; RLS policies decide who can create or cancel a booking at all.&lt;br&gt;
Wrapping up&lt;/p&gt;

&lt;p&gt;The core idea is small: lock the resource, then check, then insert, all in one transaction. Everything else (capacity, holds, payments) builds on that.&lt;/p&gt;

&lt;p&gt;I packaged this booking engine, the owner dashboard and the public booking page as a source-code starter kit for developers who build booking apps for clients. It's $99 one-time. The known limits are listed upfront: the CMI (Moroccan gateway) integration is a mock, Stripe is test mode only, and there's no automated test suite.&lt;/p&gt;

&lt;p&gt;30-second demo: &lt;a href="https://youtube.com/shorts/SKSHpPnNcOQ" rel="noopener noreferrer"&gt;https://youtube.com/shorts/SKSHpPnNcOQ&lt;/a&gt;&lt;br&gt;
Full walkthrough: &lt;a href="https://youtu.be/83ZVDX_zGSg" rel="noopener noreferrer"&gt;https://youtu.be/83ZVDX_zGSg&lt;/a&gt;&lt;br&gt;
Details and live demo: &lt;a href="https://grow-roots-landing.vercel.app" rel="noopener noreferrer"&gt;https://grow-roots-landing.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've solved double bookings differently (exclusion constraints, SELECT ... FOR UPDATE, queues), I'd like to hear what worked for you.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>react</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
