<?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: Damian Zimiński</title>
    <description>The latest articles on DEV Community by Damian Zimiński (@damian_zimiski).</description>
    <link>https://dev.to/damian_zimiski</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%2F4164024%2F2ec4f92f-f999-46b7-9955-d20471c7b30f.png</url>
      <title>DEV Community: Damian Zimiński</title>
      <link>https://dev.to/damian_zimiski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/damian_zimiski"/>
    <language>en</language>
    <item>
      <title>How I stopped double bookings with one Postgres constraint (no locks, no race conditions)</title>
      <dc:creator>Damian Zimiński</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:36:00 +0000</pubDate>
      <link>https://dev.to/damian_zimiski/how-i-stopped-double-bookings-with-one-postgres-constraint-no-locks-no-race-conditions-dio</link>
      <guid>https://dev.to/damian_zimiski/how-i-stopped-double-bookings-with-one-postgres-constraint-no-locks-no-race-conditions-dio</guid>
      <description>&lt;p&gt;I am a freelance web developer at the start of my career. To practice real client problems, I built Fade &amp;amp; Co., a booking system for a fictional barbershop. It is a concept project, no real shop uses it. One problem turned out to be more interesting than the UI: how do you make sure two customers can never book the same barber at the same time?&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach and why it breaks
&lt;/h2&gt;

&lt;p&gt;The obvious way is to check availability first and then insert the booking:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Query: is this barber free from 10:00 to 10:45?&lt;/li&gt;
&lt;li&gt;If yes, insert the booking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works until two people click "Book" in the same second. Both queries see a free slot, both inserts succeed, and the barber is double booked. The gap between the check and the insert is a race condition. You can shrink it with transactions and locks, but then you are writing and maintaining concurrency code in your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the database say no
&lt;/h2&gt;

&lt;p&gt;Postgres can enforce "no overlapping ranges for the same barber" itself, with an exclusion constraint. This is the schema from the project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;extension&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;btree_gist&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;bookings&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;service_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;staff_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;staff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;starts_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ends_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;
    &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cancelled'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ends_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;constraint&lt;/span&gt; &lt;span class="n"&gt;no_double_booking&lt;/span&gt; &lt;span class="n"&gt;exclude&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;gist&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;staff_id&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tstzrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ends_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'confirmed'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the constraint like a sentence: no two rows may have the same &lt;code&gt;staff_id&lt;/code&gt; and overlapping time ranges (&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; is the "overlaps" operator), but only among confirmed bookings. Cancelled bookings free the slot automatically, because they are excluded by the &lt;code&gt;where&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;btree_gist&lt;/code&gt; is needed so that a plain equality check on &lt;code&gt;staff_id&lt;/code&gt; can live in the same GiST index as the range check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The app only handles the error
&lt;/h2&gt;

&lt;p&gt;Now the insert itself is the check. If another booking already holds that time, Postgres rejects the second insert with error code &lt;code&gt;23P01&lt;/code&gt;, and the app turns it into a friendly message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;23P01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;That time was just taken. Please pick another slot.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something went wrong. Please try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two people clicking at the same moment cannot both win, because the database serializes the conflicting writes. The app still shows only open slots (computed from working hours, service duration and existing bookings), but that is a convenience for the customer, not the safety net. The constraint is the safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other decisions in the project
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Availability is computed in the shop's time zone, while times are stored as &lt;code&gt;timestamptz&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Row Level Security is on for every table with no public policies. All data access goes through server-side code, so the public key can never read customer data.&lt;/li&gt;
&lt;li&gt;Input is validated with Zod on the server, and the admin area uses passwordless magic links with an email allow-list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I would add next
&lt;/h2&gt;

&lt;p&gt;Email confirmations and reminders, rate limiting on the public booking action, per-barber days off, and online payments. I left them out on purpose to keep the concept focused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it and tell me what is wrong
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live demo: &lt;a href="https://fadeco-booking.vercel.app" rel="noopener noreferrer"&gt;https://fadeco-booking.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Code: &lt;a href="https://github.com/dziminski79-sudo/fadeco-booking" rel="noopener noreferrer"&gt;https://github.com/dziminski79-sudo/fadeco-booking&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stack: Next.js (App Router, Server Actions), TypeScript, Tailwind CSS, Supabase (Postgres and Auth), Zod, Vercel.&lt;/p&gt;

&lt;p&gt;I would like honest feedback. What feels confusing in the booking flow? What would a real shop owner need that I missed? If you build booking tools for a living, I would also like to hear where this approach breaks down.&lt;/p&gt;

&lt;p&gt;I take on small web projects (booking systems, client portals, n8n automations, AI chat assistants). All my portfolio pieces are concept builds, not client work, and the code is public so you can judge it directly: &lt;a href="https://damian-dev-portfolio.vercel.app" rel="noopener noreferrer"&gt;https://damian-dev-portfolio.vercel.app&lt;/a&gt;&lt;/p&gt;

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