<?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: Andy Scott</title>
    <description>The latest articles on DEV Community by Andy Scott (@andy_scott_ab7c324a542e58).</description>
    <link>https://dev.to/andy_scott_ab7c324a542e58</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%2F4202081%2F22c284bf-e9e9-428b-b8f9-90ad48ac2d7c.png</url>
      <title>DEV Community: Andy Scott</title>
      <link>https://dev.to/andy_scott_ab7c324a542e58</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/andy_scott_ab7c324a542e58"/>
    <language>en</language>
    <item>
      <title>I fired 20 simultaneous bookings at a typical booking app. All 20 got the slot.</title>
      <dc:creator>Andy Scott</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:44:35 +0000</pubDate>
      <link>https://dev.to/andy_scott_ab7c324a542e58/i-fired-20-simultaneous-bookings-at-a-typical-booking-app-all-20-got-the-slot-1n75</link>
      <guid>https://dev.to/andy_scott_ab7c324a542e58/i-fired-20-simultaneous-bookings-at-a-typical-booking-app-all-20-got-the-slot-1n75</guid>
      <description>&lt;h2&gt;
  
  
  The app you built in an afternoon
&lt;/h2&gt;

&lt;p&gt;Ask Claude Code, Cursor or Lovable for a booking app for a barbershop and you'll have one by teatime. Services, staff, a calendar, Stripe checkout. It looks done, and every test you write for it passes.&lt;/p&gt;

&lt;p&gt;Then on a Saturday morning two customers pick 10:00 with the same barber, a second apart. Both get a confirmation email. One of them turns up to find someone already in the chair.&lt;/p&gt;

&lt;p&gt;This isn't a bad model or a lazy prompt. It's the same bug in almost every booking app that gets generated or written quickly, and tests that send one request at a time can't see it. Here's what it looks like, a test that catches it, and what fixing it properly involves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check every generated scheduler writes
&lt;/h2&gt;

&lt;p&gt;Under the calendar there's nearly always an appointments table and a check for overlaps before each insert. With Prisma it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;staffId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appointment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;staffId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;start&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Slot taken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;appointment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;staffId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overlap condition is right: two ranges overlap when each starts before the other ends. Reviewers nod it through. The problem isn't the condition, it's that checking and inserting are two separate steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two customers, one slot
&lt;/h2&gt;

&lt;p&gt;When two requests arrive close together, both run &lt;code&gt;findFirst&lt;/code&gt; before either runs &lt;code&gt;create&lt;/code&gt;. Both see an empty slot, so both insert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer A                       Customer B
findFirst: no clash
                                 findFirst: no clash
create: booked
                                 create: booked   &amp;lt;- two bookings, one chair
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't take much traffic. A popular barber on a Saturday, a class that just opened, or one customer double-tapping the button on a slow phone will do it.&lt;/p&gt;

&lt;p&gt;The fix has to make the database refuse the second booking, not the application. In Postgres the cleanest way is an exclusion constraint, which rejects any row whose time range overlaps another for the same staff member:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;appointment&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;appointment_no_overlap&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;start_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the second insert fails with error &lt;code&gt;23P01&lt;/code&gt; (exclusion violation), however close together the requests are. Catch it and tell the customer the slot has just gone.&lt;/p&gt;

&lt;p&gt;One catch if you use Prisma: its default &lt;code&gt;DateTime&lt;/code&gt; column is &lt;code&gt;timestamp&lt;/code&gt; without a time zone, and Postgres refuses &lt;code&gt;tstzrange&lt;/code&gt; on it ("functions in index expression must be marked IMMUTABLE"). Declare the columns &lt;code&gt;@db.Timestamptz(3)&lt;/code&gt;, which is what appointment times should be anyway, or use &lt;code&gt;tsrange&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The constraint only covers one table and one rule. Once a booking needs a staff member and a room, or bookings can be cancelled and should free their time, you need to filter the constraint on status and lock rows in a consistent order inside a transaction, or two bookings for two resources start deadlocking each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The customer who is still paying
&lt;/h2&gt;

&lt;p&gt;Between picking a time and finishing checkout, a customer might take two minutes or twenty. Generated apps usually do one of two things here, and both go wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Book after payment.&lt;/strong&gt; Someone else books the slot while the first customer is typing their card number. They pay for a time they no longer have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Book before payment.&lt;/strong&gt; The slot is taken for good, even when the customer closes the tab. Abandoned checkouts quietly fill the diary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What works is a hold: a booking marked pending, with an expiry time, that counts as taken until it either lapses or is confirmed. That brings three rules with it.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Availability treats an unexpired hold as taken and an expired one as free, at the moment you ask, without waiting for a cleanup job.&lt;/li&gt;
&lt;li&gt;Confirming re-checks the expiry under the same lock as booking. A payment that lands a second after the hold lapsed must not confirm a slot someone else now has.&lt;/li&gt;
&lt;li&gt;Something releases lapsed holds and tells the rest of your app, so a waiting customer can be offered the time.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Clock changes
&lt;/h2&gt;

&lt;p&gt;A recurring booking is where this shows first. "Every Tuesday at 9:00" sounds like one time, but it's two different UTC times either side of the clocks changing. The usual generated code adds a week in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEEK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-10-20T09:00:00+01:00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 9:00 in London, summer time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;WEEK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// 2026-10-27T08:00:00.000Z: 8:00 in London. The clocks went back on the 25th.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The customer's standing appointment has moved an hour and nobody touched it. The fix is to store the rule in local time with its timezone ("Tuesdays, 09:00, Europe/London") and work out each date's UTC time from that, not from the one before.&lt;/p&gt;

&lt;p&gt;The edges matter too. When the clocks go back, 01:30 happens twice; when they go forward, 01:30 doesn't happen at all. A shop with a site in Madrid and one in London needs each site's hours in its own zone. None of this turns up in tests unless a test runs across a clock change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Buffers and services that need two things
&lt;/h2&gt;

&lt;p&gt;The overlap check assumes one booking uses one person for its whole length. Real services don't.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buffers.&lt;/strong&gt; A 45-minute cut with 10 minutes of cleanup blocks the chair for 55, but the customer should still see 45. The overlap check has to use the padded time while the confirmation shows the real one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two resources.&lt;/strong&gt; A massage needs a therapist and a room. Both have to be free, and both have to be taken in one step, or a second booking grabs the room between the two inserts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gaps in the middle.&lt;/strong&gt; A colour needs the stylist for 30 minutes, then not for 30 while it develops, then again. The stylist is free to take someone else in the gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Anyone available".&lt;/strong&gt; Picking the first free barber is another check-then-insert. Two customers asking for anyone can both be given Sam.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is fixable. Together they turn a table and a check into a scheduling engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test that catches it
&lt;/h2&gt;

&lt;p&gt;The fix starts with a test that fails. Fire 20 bookings for the same slot at once and count how many succeed. The answer should be one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;book&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./booking.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one of 20 simultaneous bookings for a slot succeeds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-14T10:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-14T10:45:00Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;staff_1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;booked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;booked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;Here's what it did with the Prisma code above, 20 runs of 20 bookings each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Bookings that succeeded, per run of 20&lt;/th&gt;
&lt;th&gt;Runs that double-booked&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Check-then-insert, Postgres, pool of 20 connections&lt;/td&gt;
&lt;td&gt;20 (1 on the first run)&lt;/td&gt;
&lt;td&gt;19 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check-then-insert, Postgres, pool of 1 connection&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;20 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check-then-insert, SQLite&lt;/td&gt;
&lt;td&gt;19 or 20&lt;/td&gt;
&lt;td&gt;20 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With the exclusion constraint&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things surprised me. The first run passed, because the pool was still opening connections one at a time, which spaced the requests out. So run it more than once.&lt;/p&gt;

&lt;p&gt;And neither a single connection nor SQLite saves you. All 20 reads run before any of the 20 writes, however the queries queue, so every one of them sees an empty slot.&lt;/p&gt;

&lt;p&gt;Add a second test that holds a slot, moves time past its expiry, and checks that the confirm is refused. Then one that books a weekly series across the last Sunday in October and checks every date stays at 9:00 local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build it, or hand it off
&lt;/h2&gt;

&lt;p&gt;You don't always need any of this. A table and a check are fine when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one person takes bookings for themselves and can sort out the odd clash by phone,&lt;/li&gt;
&lt;li&gt;there's no payment between picking a time and booking it, and&lt;/li&gt;
&lt;li&gt;everything happens in one timezone, with no recurring bookings, buffers or shared rooms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building for many businesses, each with staff, rooms and customers paying online, the scheduling is a product of its own. You can build it: an exclusion constraint, holds with expiry, rules in local time, locks taken in a fixed order, and tests for each. Or you can hand it to a service built for it.&lt;/p&gt;

&lt;p&gt;That's why we built &lt;a href="https://tiempo.one" rel="noopener noreferrer"&gt;Tiempo&lt;/a&gt;, a scheduling API for booking platforms. Your app keeps its services, prices, customers and payments; Tiempo keeps the time. Every booking locks the resources it touches, so the race above ends in one booking and one &lt;code&gt;409&lt;/code&gt;. Holds expire on their own, recurring bookings keep their local time through clock changes, and buffers, two-resource services and "anyone available" are built in. It has a free test mode, and a &lt;a href="https://tiempo.one/docs/build-with-ai" rel="noopener noreferrer"&gt;rules file&lt;/a&gt; you can give your coding agent so it uses Tiempo instead of writing a scheduler.&lt;/p&gt;

&lt;p&gt;To see all of it in one place, the &lt;a href="https://github.com/tiempo-one/booking-starter" rel="noopener noreferrer"&gt;booking starter&lt;/a&gt; is a small Next.js barbershop app on Tiempo: pick a service, a barber or anyone, and a time, and the slot is held while you pay, with Stripe or a built-in test page. A payment that lands after its hold ran out is refunded, not forced through. It ships with the race test from above, aimed at the real thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20 customers racing for 2026-10-23T08:15:00Z...
Held: 1. Refused with 409: 19. Other errors: 0.
Pass: one booking, no double-booking.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's MIT-licensed, and its &lt;code&gt;AGENTS.md&lt;/code&gt; tells your coding agent how it uses Tiempo, so you can fork it and ask the agent to turn it into your product.&lt;/p&gt;

&lt;p&gt;Whichever way you go, write the race test first. It's ten lines, and it tells you whether your booking app can be trusted on a busy Saturday.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>javascript</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
