How to prevent double booking
How to prevent double booking holds the slot for five minutes, confirms it inside one D1 batch, and keeps a unique start so only one guest wins.
How to prevent double booking is the write that lets one guest keep 13:00 UTC. Calendly states the rule on the card. A final availability check runs inside the booking transaction.
You pay $12–$20 a person a month for Calendly. That is $144–$240 a year, and the price scales with the team. The verdict is “Very vibeable”. The first estimate is 6 hours for one meeting type, weekly hours, and bookings. The lock sits inside that row.
The Calendly teardown is why the slot list is already stale when two people click. The Calendly guide builds the page around that lock. The hosting guide puts the Worker and the D1 database slot on book.flaviocopes.com. This guide is the hold, the batch, and the unique start.
A vote on five times, with no hours to protect, is the Doodle guide. Doodle is $7–$9 a person a month, about 3 hours. Build that if the group is picking. Build the lock if you already published hours.
How to prevent double booking on one start
The meeting type is intro-30. Intro call, 30 minutes, Monday to Friday 09:00 to 17:00 in America/New_York, 15 minutes of buffer after, 4 hours of notice, a 14-day horizon. The start in this walk is 2026-10-27T13:00:00.000Z. That is 09:00 in New York and 14:00 in Rome.
Jordan Lee and Sam both have the page open. Both see 13:00. The list was true when it rendered. It is a copy.
Jordan clicks at 2026-10-26T18:02:00.000Z. The app writes a hold that expires at 2026-10-26T18:07:00.000Z. Jordan gets the form. Sam clicks at 18:04. The unexpired hold is already there, so Sam gets the slot-taken page and the next two starts that still fit, 14:00 UTC and 14:30 UTC. 13:30 UTC sits inside the 30-minute meeting plus the 15-minute buffer, so it is not an alternative.
If Jordan never submits, the next read after 18:07 treats the hold as gone. The slot is on the list again. There is no cron for that. The Calendly guide already releases holds on read.
If both guests somehow submit 13:00 together, one insert lands. The other hits the unique index. The card’s scenario is the same sentence: two guests, one confirmed booking, one slot-unavailable response.
Where the check and the insert split
Every product prompt ships in two flavors: Node.js plus Astro, and TanStack Start. The card lists Astro, HTMX, and D1. This site is an Astro app on Cloudflare, so the check lives in the Worker and the rows live in D1. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. On that tab you use a real SQL transaction and the same unique index.
D1’s batch() sends a list of prepared statements. They run sequentially and non-concurrently. Cloudflare’s docs call that list a SQL transaction: if one statement fails, the batch rolls back. Two separate await calls are a different shape. Each one commits on its own. Jordan’s read can finish, Sam’s read can finish, and both inserts can still be attempted.
The confirm batch is three statements, sent together.
- Insert the confirmed booking for that meeting type and start, and only if the hours, the notice, the horizon, the buffer, and unexpired holds still allow it.
- Delete the hold that belongs to this guest.
- If the insert fails, the delete rolls back with it.
The unique index is what makes the second insert fail when two batches are queued. D1 runs queries one at a time on a database, which is in the limits. That queue is not the lock. Two requests can both observe an open slot before either insert runs.
On TanStack Start the screen is the same. The transaction is BEGIN on your SQL database, the insert, the hold delete, then COMMIT. A unique violation rolls that transaction back. You still show Sam the two alternatives.
What a hold and a booking store
A hold is the meeting type, the UTC start, an expiry, and a token the form sends back. Jordan’s hold is intro-30, 2026-10-27T13:00:00.000Z, expiry 2026-10-26T18:07:00.000Z. The holds table has one row per meeting type and start. A partial index cannot express “unexpired”, because now is not a fixed value SQLite will put in an index. The create-hold batch deletes that row only when expires_at is already past, then inserts. If Jordan’s row is still live, the delete removes nothing, the insert hits the unique pair, and the batch rolls back. Sam gets slot-taken.
A booking is the same pair plus the guest. Jordan Lee, [email protected], guest timezone Europe/Rome, status confirmed. Sam never gets a row.
The index covers confirmed rows only. A later cancel can keep the old row and drop it out of the index, which is how a start becomes free again. That cancel link is the 2-day row on the card, with reschedule. The index has to be partial from the start, or the cancel row blocks the hour forever.
CREATE UNIQUE INDEX one_confirmed_start
ON bookings (meeting_type_id, start_at)
WHERE status = 'confirmed'
SQLite allows that WHERE. D1 uses SQLite’s SQL. The insert that races Jordan uses the same start, fails this index, and the batch that contains it rolls back. Sam’s hold delete does not stick on that failure, so the form can be retried on a different start.
Calendly’s scheduling API says the start must still be open when the invitee is created. A missing slot is a 404, and their docs say to query availability again and offer alternatives. The teardown walks that. You do not call their API. You copy the outcome: one winner, and a specific next step for the loser.
The screens a collision has to show
The public list can mark 13:00 as open for both guests. The three screens that matter are the held form, the slot-taken page, and the owner list with a single row.
Held form
Jordan’s form names the start in UTC and in both zones, and it names the hold expiry. Name, email, timezone. The submit posts the hold token. A refresh after 18:07 shows the form as expired and sends Jordan back to the list.
Slot taken
Sam’s page says 13:00 just went. It lists 14:00 UTC and 14:30 UTC, with zone labels. It does not offer a disabled 13:00 button that still submits.
Owner list
/admin/bookings shows one row. Jordan, intro-30, 13:00 UTC, confirmed. A second line for Sam means the unique index is missing or the insert caught the error and wrote a row anyway.
The path from two clicks to one row
Jordan picks 13:00. The hold insert is its own statement, with the same uniqueness rule for unexpired holds. Sam’s click finds that hold and stops. Jordan submits. The batch inserts the booking and deletes the hold. The owner list shows one row.
If the hold has expired by submit time, the batch refuses and the list shows 13:00 again. If a second batch inserts the same confirmed start, that statement fails and the whole batch rolls back.
Prompt 1. A hold row in D1
The page and the hours already exist if you followed the Calendly guide. Paste this next.
Add holds for Slot. Node.js and Astro. Store them in D1.
When a guest picks a start, run one batch:
- delete the hold for that meeting type and start only if
expires_at is already past
- insert meeting type, start_at in UTC, expires_at five
minutes from now, and a token the form posts back
Unique (meeting_type_id, start_at) on holds.
If a live hold is still there, the delete removes nothing,
the insert fails, and the batch rolls back.
The second guest sees slot-taken, not a second form.
A partial index on expires_at > now is not valid here.
A hold in memory is invalid. Two Workers must see the same row.
Expired holds stay in the table. The slot list ignores them.
No cron.
Done when Jordan can hold 2026-10-27T13:00:00.000Z
and Sam, in another browser, cannot open a form for it
until that hold's expires_at has passed.
On the TanStack Start tab, the hold is a row in your SQL database, written from a server function. The five-minute rule stays.
What to check after the hold
Open /book/intro-30 as Jordan. Pick 13:00 UTC on 27 October 2026. The form shows the expiry. Open the same start as Sam before five minutes pass. Sam sees slot-taken. Wait out the hold without submitting. Reload the list. 13:00 is offered again.
Restart the dev server in the middle of the five minutes. Jordan’s form still submits, because the hold is in D1. If the restart cleared it, the hold was in memory.
Prompt 2. Confirm inside one batch
Confirm a booking in one D1 batch(). Do not await a SELECT
and then await an INSERT as two calls.
The batch:
- inserts one confirmed booking for that meeting type and
UTC start, only when the weekly hours, the 15 minute
buffer, the 4 hour notice, the 14 day horizon, confirmed
bookings, and unexpired holds still allow it
- deletes this guest's hold
A failed statement rolls the whole batch back.
Add this index. Confirmed rows only:
CREATE UNIQUE INDEX one_confirmed_start
ON bookings (meeting_type_id, start_at)
WHERE status = 'confirmed'
A unique violation is the slot-taken response.
Do not catch it and insert a second row with a new id.
Done when two overlapping confirms for
2026-10-27T13:00:00.000Z leave one booking.
The loser gets 14:00 UTC and 14:30 UTC, not 13:30.
TanStack Start uses one SQL transaction for those two writes, with the same partial unique index. The overlap test is the same.
What to check after the batch
Submit Jordan’s form with [email protected] and Europe/Rome. The confirmation shows 13:00 UTC, 09:00 in New York, 14:00 in Rome. /admin/bookings has one row.
Run a second confirm for Sam at the same start, overlapping the first. Count rows. One. Sam’s response names 14:00 UTC and 14:30 UTC. 13:30 UTC is absent because of the buffer after a 13:00 meeting.
If you see two rows, the index is missing or the catch block inserted anyway. Read the migration. WHERE status = 'confirmed' has to be on the index. A unique index on every status will also block a later cancel from freeing the hour, and that cancel is the 2-day row.
Prompt 3. The loser page and the stale list
Build the slot-taken screen.
Copy: this start just went.
Then the nearest two starts that still pass hours, buffer,
notice, and horizon, each with a zone label.
The public list may still show 13:00 for a guest who loaded
it a minute ago. Submitting that stale button creates a hold
or confirms, and both paths use the rules above.
A disabled button is not the lock.
A missing email keeps Jordan's hold and redisplays the form.
Do not send email on this row.
What to check after the loser page
Load the list, then from the other browser book 13:00, then submit the stale 13:00 from the first browser. You land on slot-taken. The owner list still has one row. Clear the email field and submit. The hold is still there and the form still shows the name you typed.
Failures the agent ships anyway
Two round trips called a transaction
The code awaits “is it open?” and then awaits the insert. Both guests pass the read. D1 will run those queries one at a time and still accept both inserts if nothing unique rejects the second. Put the insert and the hold delete in one batch().
The unique index on all statuses
UNIQUE (meeting_type_id, start_at) with no WHERE treats a cancelled row as a taken hour. The 2-day row is supposed to free the slot on cancel. Limit the index to status = 'confirmed'.
A hold that dies on restart
The agent stores the five minutes in a Map on the Worker. The second instance, or a restart, never sees it. Sam gets a form. The hold table is the shared copy. The hosting guide is why that table is remote D1, not the file under .wrangler only.
The catch block writes a duplicate
The unique insert throws, the catch generates a new id, and Sam gets a booking. The catch returns slot-taken and the two alternatives. It does not retry with a different primary key.
Read replication on this database
A replica can still list 13:00 after Jordan’s insert has committed on the primary. Leave replication off for slot on this v1. The confirm reads and the insert go to the same D1 database, in the batch.
A cleanup cron for holds
Expired holds are invisible to the list and to the confirm. A cron that deletes them is a second moving part. The next read is enough. The card’s integrations are Google Calendar and email, both the 2-day row, not a sweeper.
Deploy the lock with the page
The host is the one from the hosting guide. One Worker, database slot, book.flaviocopes.com.
Book 13:00 from a phone as Jordan. From a laptop, within the five minutes, try Sam. Then look at /admin/bookings on the public host. One row. Export D1 before you cancel the $12 plan.
Google Calendar busy time is the 2-day row, 16 hours on the card. The guardrail you can ship today only knows about your own holds and bookings. The non-goal still stands: no teams, no round robin, no payments, no second calendar provider.
What to skip on purpose
Skip the cron, the replica, and the in-memory hold. Skip guest accounts. Jordan and Sam type a name and an email.
Reschedule is cancel plus a new insert, in one transaction, with the same check. The teardown describes that pair. Add it when a guest asks to move. Payments and round robin stay on the month row, 160 hours, with every calendar provider.
Start now
Go to Build your own Calendly. Copy the Astro prompt, or TanStack Start if that is what you write.
The guardrail is the sentence to paste above it. Final check inside the booking transaction. Then the index on meeting type plus start, for confirmed rows only.
Hold 13:00 as Jordan. Submit as Sam before the hold expires. Open the owner list and count.