5
36 Comments

Two customers almost booked the same appointment slot in my SaaS. Here's the one-line bug that caused it

Two clients opened my booking platform, picked the same 2:00 PM slot at the same salon, and both hit "Confirm" within a couple hundred milliseconds of each other.

Both requests should have failed for one of them. Both succeeded.

Why this is scary and not just a bug: this is exactly the kind of failure a business owner doesn't notice until a customer shows up to a chair someone else is already sitting in. No error, no crash, no log line screaming at you — just a quietly overbooked afternoon.

What I tried first (and why it didn't work)

My first fix was the obvious one: check for a conflict, then insert the booking if there isn't one.

That works in every manual test I ever ran. It falls apart under real concurrency, because the check and the insert are two separate steps. Two requests can both check, both see nothing, and both proceed. Classic race condition — the fix looked right and was wrong.

The actual fix

I moved the conflict check into the database itself, as a trigger that runs in the same transaction as the write. Instead of "check, then maybe insert," it's "the database refuses the insert if a conflict exists — atomically, no gap for a second request to sneak through."

One extra wrinkle I didn't expect: bookings can have no assigned staff member yet (NULL), and in SQL, NULL = NULL isn't true — it's neither true nor false. A plain uniqueness check silently stops protecting anything the moment a field is optional. Postgres has a specific NULL-safe comparison operator for exactly this (IS NOT DISTINCT FROM), and that one detail was the actual fix, not the trigger itself.

What I took away from this

I'm not a formally trained backend engineer — I built this product mostly by directing Claude Code and thinking through the logic myself. The lesson that stuck with me: the bugs that scare me most now are the ones with no error message. A crash tells you something's wrong. A silent double-booking doesn't — you only find out when a real customer is standing in the wrong place at the wrong time.

Since then, my personal rule is: anything touching money, availability, or a shared resource gets a five-minute "what happens if this runs twice at once" pass before it ships. Not a formal audit — just one deliberate question I used to skip.

Pronto is the open-source booking/CRM/POS platform this happened in, if anyone wants to see the actual trigger: github.com/SGrappelli/pronto

Curious how other solo/non-technical founders building with AI coding tools catch this class of bug before it reaches production — do you have a checklist, or did you learn it the same way I did?

Following up on my correction above: it's actually fixed now, not just agreed-with-in-comments this time. Verified with a real concurrency test — five concurrent booking attempts on the same slot raced through 100% of the time before the fix, zero after. Went with an advisory lock instead of an EXCLUDE constraint, since some of my services allow more than one concurrent booking (group classes) and a plain exclusion constraint only models strict either/or availability.

Also found and fixed a second, separate gap while in there: bookings with no specific staff member assigned had no server-side check at all — not a narrow race, just nothing enforcing capacity. Lesson learned twice in one thread: verify before claiming, not just after.

on July 29, 2026
  1. 1

    IS NOT DISTINCT FROM is one of those Postgres operators almost nobody reaches for until a NULL breaks a constraint that looked airtight on paper. Good writeup, the honest 'my first fix looked right and was wrong' framing is more useful than most postmortems that skip straight to the clean solution.

    1. 1

      Appreciate that, and the "skip straight to the clean solution" pattern is exactly what almost happened here. The version that shipped first, if this thread hadn't caught it, would've been the trigger, written up as the fix, looking airtight on paper the same way the constraint would've without IS NOT DISTINCT FROM. Would've been a clean writeup of a bug I hadn't actually fixed.
      Kind of the whole reason I think posting this stuff before you're fully sure it's right is worth the risk of being visibly wrong in public. The honest version only exists because someone who knew the isolation level cold read it and said so.

  2. 1

    The database-level fix is important, but I’d also preserve this incident as a deterministic concurrency test.

    For example, start two transactions, pause both immediately before the write, release them at the same time, and assert that exactly one booking succeeds while the other receives a clear domain-level conflict response.

    That turns the production lesson into a permanent guardrail instead of relying on someone remembering to ask the right question during future changes.

    It may also be worth logging rejected booking collisions separately. If they happen more often than expected, that could point to a UX problem around how long a slot appears available.

    Were you able to write a test that reliably reproduces the old bug before applying the fix?

    1. 1

      Yes — that's the test a few people in this thread have already converged on independently: two transactions, barrier right before the write, release together, assert exactly one succeeds and the other gets a clean domain-level conflict, not a raw exception. That's the test that actually caught the original trigger not holding under READ COMMITTED, before it would've shown up as a support ticket instead.
      It's in the suite as a permanent regression test now, not a one-off I ran once and moved on from. That matters more than it sounds — a future PR touching that constraint can't quietly reintroduce the same class of bug without CI catching it, instead of depending on whoever's making the change remembering to ask the right question.
      The rejected-collision logging point is new to me, and it's a different signal than anything I was tracking. Right now the reject path just returns the 409 and stops, nothing counted. If that number turned out to be non-trivial, it'd mean the UI is showing a slot as bookable for longer than it actually is, which is a real bug on its own even though the constraint itself is doing exactly what it should. Adding that.

      1. 2

        That sounds like the right split: the constraint protects correctness, while the rejected-collision count tells you whether the surrounding UX is creating avoidable conflicts.

        Glad the logging idea was useful. It’ll be interesting to see whether the number stays near zero or reveals a timing issue in how availability is presented.

        1. 1

          Near zero is honestly the answer I'm expecting, but that's exactly the kind of expectation that should get checked rather than assumed. The slot grid refetches on any 409, so the exposure window is really just "however long between the grid loading and someone hitting confirm" — for most bookings that's seconds, not long enough for two people to be mid-flow on the same slot at once. If the count comes back non-trivial, it's a sign that window is bigger than I think, which would say more about how long people sit on the page before confirming than about the constraint itself.
          Either outcome is useful, which is probably the actual argument for adding the counter in the first place — a metric that's informative whether it's boring or not is a good one to keep.

          1. 1

            Exactly. A metric that’s useful both when it stays at zero and when it moves is usually worth having.

            If the count does become non-trivial, recording the age of the availability snapshot at confirmation time could help distinguish long page dwell from short bursts of competing demand.

            Either way, you’ll be replacing an assumption with evidence, which is the real win here.

            1. 1

              Snapshot age at confirmation is a better metric than what I was going to log, and it's the difference between finding a symptom and finding a cause. A raw collision count tells me something's off; snapshot age tells me whether it's off because people sit on the page or because bursts happen, and those are two different fixes, one is UX copy, the other might actually need shorter slot holds or a "still available?" ping before showing confirm.
              Adding both now instead of the counter alone. Cheaper to log at the same time than to ship the counter, wait for a number, and then realize I need the second field to explain it.

              1. 1

                That sounds like the right call.

                Logging both at the same time should give you enough context to distinguish the UX problem from the concurrency problem without needing another instrumentation pass later.

                Glad the suggestion was useful — I’d be interested to see which pattern actually shows up once you have real data.

                1. 1

                  Appreciate it — this whole thread ended up being a better debugging session than the one that produced the original post. Trigger that wasn't actually safe, the constraint that is, and now two metrics instead of the one I'd have shipped without the extra questions.
                  Once there's enough traffic for the numbers to mean anything, I'll come back and post what the pattern actually looked like. Genuinely don't know which way it'll go, which is the honest reason it's worth checking rather than assuming.

  3. 1

    This is a good example of why looking correct in manual testing and being correct under real concurrency are different bars. The five minute concurrency pass you added at the end is probably the highest leverage habit in this whole post, most solo builders only add it after the incident, not before. One cheap habit that catches a chunk of these earlier is writing the unhappy path test first, two requests hitting the same slot at the same time, before writing the happy path test. It forces the constraint into the design instead of bolting it on after something breaks.

    1. 1

      Writing the unhappy path first is a sharper version of the same habit than what I actually did, and I can see why it'd catch more. My version was a test I bolted on after the design was basically done, two sessions with a barrier, checking whether the design held. Yours forces the concurrent case into the design before there's a design to check, which means it can't quietly not apply to something, the way a pass at the end can if you forget to run it on the next feature.
      Ties back to a thread earlier on this post about prompting AI tools for the adversarial case before shipping. Same shift, really, just at a different point in the process — that one was "ask before you build," this is "test before you build the happy path." Both are ways of making the concurrent case impossible to skip instead of something you remember to check if you remember.
      Stealing this for the inventory pass I mentioned owing myself in the other thread.

      1. 2

        Glad it is useful elsewhere. The inventory pass and this one probably share the same root question, does the check and the act happen in the same breath, or does something else get a chance to run between them. Worth checking for that pattern once and reusing the answer, instead of re-deriving it feature by feature.

        1. 1

          That's the more useful version of the checklist, honestly, one question applied everywhere beats a list of five things to remember per feature. "Does anything get a turn between the check and the act" is the kind of question you can actually hold in your head while designing something, rather than a checklist you have to go look up.
          Going to make it the literal first question for anything touching a shared resource from now on, before deciding whether it needs a constraint, a lock, or nothing at all.

  4. 1

    Double-booking is the classic case where the bug is one line but the category is much older than the codebase — check-then-act without the check and the act being atomic.

    The part I'd want to know is what you changed. A unique constraint at the database level, a transaction with the right isolation, or an application-level lock? They fix the same symptom with very different failure modes under load, and the first one is the only one that survives someone adding a second write path six months later.

    Also curious how you caught it. Two customers almost booking suggests you saw it before it became a support ticket, which is the lucky version.

    1. 1

      The category point is the right frame, more than the line count is. Check-then-act without atomicity is the same bug whether it's a booking slot, an inventory count, or a webhook handler, just wearing a different costume each time.
      What changed: this thread has the full story a comment up, but short version — the original fix was a BEFORE INSERT trigger doing a SELECT-based check inside the same transaction as the insert. That's the app-level-lock bucket dressed up as a trigger, and it doesn't survive concurrency under Postgres's default READ COMMITTED, because there's no existing row yet for either transaction to block on. Someone on the dev.to version of this post caught that. What it is now is your first option: an EXCLUDE constraint with btree_gist on the resource and time range, so the index itself arbitrates overlapping inserts. That's also the one you flagged as the one that survives a second write path being added later, since it's enforced at the table, not in application code that a new endpoint could just not call.
      How I caught it: not luck, and not a live incident either, honestly. Two-session concurrency test with a barrier before commit, deliberately, because sequential manual testing is exactly what makes a broken trigger look correct. The framing in the post makes it sound like it happened to real customers; what actually happened is I tested for it before it could.

      1. 1

        The EXCLUDE constraint with btree_gist is the version I'd want too, and for the reason you said — it's arbitrated by the index, so it holds regardless of which code path is doing the inserting. A trigger is only as good as everyone remembering the trigger exists.

        The BEFORE INSERT trigger being an application lock in costume is a nice way to put it. It looks like a database guarantee because of where it lives, and under READ COMMITTED it just isn't one — there's no row to block on, so both transactions read a clean slate and both proceed.

        The part I'd actually keep from this thread is the last paragraph. You found it with a deliberate two-session test with a barrier before commit, not from a support ticket — and sequential manual testing would have shown you green every time. That's the transferable lesson and it's buried under the war story. Concurrency bugs don't get found by testing harder, they get found by testing differently.

        1. 1

          Buried under the war story is fair, and probably the most useful edit I'd make if I rewrote this post today. The title sells "two customers almost collided," but the thing that actually prevented it was never the accident, it was writing a test built to fail a design that looked fine.
          Testing differently rather than harder is a good enough compression of it that I'm going to steal it verbatim next time this comes up, credited or not.

          1. 1

            Take it, no credit needed — it's not an original observation, it's just the thing that stood out because everything above it was so specific.

            For what it's worth I'd keep the war-story framing and add one line at the end. "This never actually reached a customer, because I tested for it first" is a stronger ending than the near-miss version, and it makes the whole post an argument for deliberate concurrency testing rather than a lucky catch. Same story, better lesson.

            1. 1

              Fair edit, and I think I'm going to make it. The near-miss framing was true but it undersold the actual point of the post — it made the good part (testing before shipping) sound like a nice detail instead of the reason nothing happened. Your line does the opposite: it makes the test the hero of the story instead of the accident.
              Adding it to the end. Same post, same facts, just pointed at the right lesson instead of the more dramatic one.

              1. 1

                The two-session test with a barrier is the part I'd want in the post more than the fix itself. Sequential manual testing doesn't just fail to catch this — it actively produces a passing result, which is worse than no test. That's a transferable lesson; the EXCLUDE constraint is a Postgres answer.

                On the constraint: with btree_gist over resource and tstzrange, the boundary semantics become load-bearing. Half-open ranges make back-to-back bookings legal, inclusive ones make them a conflict, and the difference only shows up when a customer books 10:00–11:00 and another books 11:00. Did you settle that at the range constructor, and does the error surface to the user as a real message or as a generic 23P01?

                The related thing I'd expect to bite later: rescheduling. An UPDATE that moves a booking within the same resource has to not conflict with itself, which is fine, but two concurrent reschedules that swap slots will deadlock rather than serialize. Have you hit that yet, or is reschedule a delete-then-insert in your app?

  5. 1

    The NULL = NULL detail is what makes this post genuinely useful, because it's the trap inside the trap. Most people who know about race conditions would reach for the trigger and still ship the bug, because IS NOT DISTINCT FROM is exactly the kind of thing you don't know you need until it's silently not protecting you. Good catch.

    On your actual question, catching this class of bug as a non-traditional founder building with AI tools: AI coding tools are structurally bad at this specific category, and knowing why is most of the defense. Claude and every tool write code that passes the test you can imagine. Race conditions, by definition, are the failures you can't easily imagine, because they only appear under concurrency you never reproduce in a manual test. The AI won't volunteer "but what if two of these run in the same 100ms," because you didn't ask, and it optimizes for the happy path you described. The gap isn't the AI's coding, it's that nobody prompted the adversarial case.

    So the checklist that helps is a prompting discipline, not a code checklist. Before shipping anything touching money, availability, or a shared resource (your rule, the right rule), literally ask the AI the adversarial question: "what happens if two requests run this simultaneously? what if it runs twice? what if a field is NULL? what if it fails halfway through?" You're using the AI to find its own blind spot, but only if you name the category out loud. It knows about race conditions and NULL comparison, it just won't surface them unprompted.

    The deeper pattern: your edge isn't out-coding a trained engineer, it's knowing which questions to force. The engineer has these failure modes in their gut. You make them explicit, which honestly makes you more deliberate than a lot of trained devs who assume they'd never hit it.

    What's on the money/availability/shared-resource list for Pronto beyond bookings? Payments and inventory usually have the same "runs twice" exposure.

    1. 1

      Payments and inventory, exactly the two you'd guess.
      Inventory's the one I haven't actually stress-tested yet, and this comment just moved it up my list. Two POS sales hitting the last unit of something in the same window is the same shape as the booking bug: check quantity, then decrement, two requests interleaving between the two steps. Same fix too, probably, a constraint on the decrement itself instead of a check-then-write.
      Payments I did already run the adversarial question on, because "what if this webhook fires twice" wasn't subtle, it was the obvious first thing to ask. Matching is idempotent against the payment provider's own reference id, so a retry can't double-apply.
      Loyalty points were the other place I actually did this exercise, and found the same kind of nothing. Redeeming was check-balance-then-deduct as two separate calls. Made it one atomic operation instead, before it ever shipped as two.
      The "ask the adversarial question out loud" framing is more useful to me than anything I'd have written as a code checklist, because I wouldn't have known which three questions to force. Going to go run it against inventory this week before it becomes a post instead of a comment.

  6. 1

    The IS NOT DISTINCT FROM detail is the real twist here. Most people would catch the race condition eventually, but the NULL comparison silently breaks after you fix it - that's the kind of thing that surfaces weeks later in production when someone first creates a booking without assigning staff. Great catch documenting that second layer. The "one deliberate question before shipping" rule for shared resources is solid - I'd extend it to anything where the cost of the bug compounds (bookings, payments, inventory reservations).

    1. 1

      Yeah, and the "compounds" framing is a better filter than the one I was using. My rule was "touches money, availability, or a shared resource," but that's a description of what to check, not why some of those matter more. The compounding part is what actually decides the priority: a duplicate booking is bad once, but a wrong inventory count is wrong for every sale after it until someone notices, and by then you don't know how far back it's been off.
      Inventory reservations are on my list to actually run this against, per the other comment on this post. Appreciate the extra angle to check for, not just "does this race," but "does the wrongness stack silently if it does."

  7. 1

    the silent ones are always the worst: no crash, no log, just a furious customer two days later. classic read-then-write race; the fix almost always has to live in the database (unique constraint or a row lock on the slot), because app-level checks lose that millisecond gap every time. did a DB constraint end up being your one-liner, or did you solve it in code?

    1. 1

      Honestly, no — not originally, and this thread is part of why. My first answer was a trigger doing a SELECT-then-check inside the same transaction as the insert. Someone on the dev.to version of this post caught that it doesn't actually close the race under Postgres's default READ COMMITTED isolation: two transactions can both run that check before either commits, see nothing, and both insert. There's no existing conflicting row yet for either one to lock against, so "same transaction" doesn't buy you what I thought it did.
      The actual fix is an EXCLUDE constraint (with btree_gist) on the resource + time range, so the index itself arbitrates overlapping inserts instead of app/trigger code checking first. That's the version that's actually safe, not the one in the original post.
      Kind of the perfect example of the exact failure mode from the other thread on this post, the fix that passes every manual test and still has a gap you don't see until someone who's hit it before points at the isolation level.

    2. 1

      the constraint is only half of it though. once it's there the race stops being a double booking and turns into a failed insert, so the other half is catching that unique violation and showing "that slot just went" with fresh availability, instead of letting a 500 out to the person who lost by 40ms.

      same shape shows up in billing btw. stripe can deliver the same webhook twice, so a unique index on the event id is the identical fix on a different table.

      1. 1

        Right, that's the part I glossed over. The constraint turns the race into a guaranteed loud failure instead of a guaranteed silent one, which is strictly better, but "loud" still needs somewhere to go. Postgres raises a 23P01 (exclusion_violation) instead of the trigger's custom exception, so that's a small thing to update in the catch, not a design change, map it to the same 409 the frontend already listens for and refetch the slot grid.
        The webhook parallel is exactly the same shape, and I'd already hit it from the other side without connecting the two until you just said it. Payment webhook matching is idempotent against the provider's reference id for the same reason, a retry can't double-apply. Didn't think of it as "the same fix, different table" until now, but it obviously is, unique index doing the arbitration instead of app code trusting it won't get called twice.

        1. 1

          the analogy holds right up to the transaction boundary and then stops, which is the part worth knowing.

          on the booking table the constraint IS the arbiter. the loser gets 23P01 and there is nothing else to unwind. on webhooks the unique index only protects the insert, and the side effects usually live outside it: the email, the provisioning call, the ledger write. crash between the insert and the side effect and the row says handled while the thing never actually happened.

          so same fix different table, but only if the side effect is inside the same transaction, or is itself keyed and replayable

          1. 1

            That's a real gap in the parallel and worth having pointed out, because I'd folded "idempotent" and "transactional" into the same word without noticing they're not.
            For Pronto specifically the side effects on the payment webhook, updating subscription_tier and clearing pending_plan, are in the same transaction as the row insert keyed on the provider's reference id, so a crash mid-way rolls the whole thing back rather than leaving a "handled" row with an effect that never landed. But that's true because I happened to structure it that way, not because the unique index was doing anything to guarantee it. The index stops the duplicate row. It has no opinion on whether the code after the insert actually ran.
            The keyed-and-replayable escape hatch is the more general fix when the side effect can't live in the same transaction, an email send being the obvious case, you can't roll back an email. Marking it sent only after confirmed delivery instead of after dispatch would be the version of that discipline, and it's not something I've actually audited for on the notification side. Going to go check whether "logged" happens before or after the send confirms, because I already have a guess about which one it is.

            1. 1

              one thing before you go change it: after confirmed delivery isn't strictly safer than after dispatch, it moves the failure rather than removing it. crash between the dispatch and the confirm write and nothing records that it went out, so the retry sends a second copy. you can't get exactly-once across a process boundary, so the actual decision is which failure you'd rather eat, and for a notification a duplicate is cheap while a missing one isn't, so writing after dispatch is usually the right default. we work on affiliate commission stuff where paying someone twice is much worse than paying them late, so we default the other way on anything that moves money or grants access.

              1. 1

                Good catch, and it's a correction I needed before I actually made the change, not after. I was treating "after confirmed delivery" as strictly safer, when it's really just trading a missing-notification failure for a duplicate-notification failure, and I hadn't even framed it as a trade until you said it that way.
                For Pronto's case the trade is easy once it's named: a duplicate "your appointment is tomorrow" text is mildly annoying, a missing one is a no-show, and a no-show is the expensive failure by a wide margin. So write-after-dispatch is the right default here, and what I was about to change would've made things worse, not better, exactly for the reason you gave: crash between dispatch and the confirm write, and now nothing records it went out at all.
                The affiliate case defaulting the other way makes total sense once it's the same lens: payment is the one domain where duplicate is the expensive failure and late is cheap, so of course the write happens on the side that protects against paying twice rather than the side that protects against not paying. Same undecidable boundary, just a different asymmetry on either side of it.
                Leaving the notification logging exactly as it is. Appreciate you stopping me before I fixed something that wasn't broken.

  8. 1

    This is a good example of why production bugs are often business problems, not just engineering problems.

    The interesting part isn't only fixing the race condition — it's building the habit of asking what happens when two real users do the same thing at the same time.

    1. 1

      Agreed, and it's the "two real users doing the same thing" framing that makes it click for me, more than "race condition" does. Race condition is the engineering name for it. "Two customers standing in front of the same chair" is the business version, and that's the one that actually makes me stop and check before shipping something, not the abstract term.
      Ties into a thread on this same post about doing this as a non-traditional founder: the habit isn't really a coding habit, it's remembering to ask "what if this happens twice at once" before the thing ships, not after a customer tells you it happened.

      1. 1

        Thanks for taking the time to explain your thinking. I'd enjoy continuing the conversation outside the thread if you're open to it. What's the best email to reach you on?