Back to blog
By the Lesscode team

Why Two Customers Can Book the Same Slot

A booking link looks like a form. It is not. It is a write operation against a scarce resource, executed by several people at once, from several time zones, with a payment or a callback usually waiting on the result. The moment two customers open the same 09:00 slot, you are running a concurrency problem with a calendar interface on top.

Most double bookings are not caused by bad UI. They are caused by code that checks availability and then writes, with a gap in between. This is the design we build into scheduling systems, and the failure modes we look for when reviewing one that has started losing slots.

The race is smaller than you think

The naive implementation is two statements:

const taken = await db.bookings.findFirst({ where: { resourceId, startAt } });
if (taken) return reject("slot taken");
await db.bookings.create({ data: { resourceId, startAt, customerId } });

Between the read and the write there is a round trip to the database. On a typical managed database that is one to five milliseconds; under load with a connection pool, it is tens. That window is irrelevant until it is not: a popular slot, a marketing email, and forty people clicking the same link will produce collisions inside a window that no developer can reproduce locally.

Two failing patterns follow from this. The first is a read-modify-write that loses: both requests see the slot free, both insert, and the calendar now holds two customers for one technician. The second is worse because it is silent — a "last write wins" update on a bookings table where the second write overwrites the first row's customer, so nobody sees a double booking. Instead, one customer arrives to a booking that no longer exists under their name.

Holds, not locks

A slot needs to be reserved before the customer finishes typing their card details or answering intake questions. That means a short-lived reservation, not a database transaction held open while a human thinks.

The shape that works:

  1. When the customer selects a slot, insert a hold row with expires_at = now() + 10 minutes and a token returned to the browser.
  2. Every subsequent step in the flow carries that token. Confirming converts the hold into a booking; abandoning it lets it expire.
  3. The checkout page shows the remaining time. A hold that silently vanishes at the payment step is a support ticket, not a safety measure.
  4. Expiry is enforced by the database on read, not only by a background job. A cron that cleans up every five minutes is bookkeeping, not correctness.

The subtle failure here is holds that only exist in memory or in a cache without persistence. They disappear on deploy, they diverge between instances, and they cannot be reasoned about when a customer refreshes the page mid-flow. A hold is a row.

Let the database be the referee

Application-level checks are advisory. The constraint is the guarantee. In PostgreSQL, the honest way to prevent overlapping bookings for a resource is an exclusion constraint over a time range:

create extension if not exists btree_gist;

create table bookings (
  id           bigserial primary key,
  resource_id  uuid not null,
  during       tstzrange not null,
  customer_id  uuid not null,
  status       text not null default 'confirmed',
  constraint no_overlap
    exclude using gist (resource_id with =, during with &&)
    where (status = 'confirmed')
);

Now the second insert fails at the storage layer, whatever the application believed a millisecond earlier. Your code catches the constraint violation and returns "that slot just went," which is a truthful answer. It also closes the door on the multi-instance problem: two app servers, two serverless functions, one referee.

MySQL and SQLite lack exclusion constraints, so the equivalent is a unique index on a discretised key — (resource_id, slot_start) at whatever granularity you actually sell, fifteen or thirty minutes — plus a check that the duration never crosses a slot boundary. Discretising is a real design decision: it means the business sells fixed-length appointments. If it sells arbitrary durations, use a database that can express the constraint.

One detail that bites teams later: soft deletes and cancellations. The constraint above applies only to status = 'confirmed', which is what you want, because a cancelled booking must free the slot. But if your cancellation sets a flag instead of changing status, the constraint keeps blocking the slot and the slot silently disappears from the bookable set.

Time zones are part of the data model

Store the instant, keep the zone. A booking is a timestamptz plus the IANA zone the customer booked in plus the business's zone. Do not store a local wall-clock time and hope.

Two transitions break naive implementations every year. In the spring, clocks skip an hour, so local times in that window do not exist. In the autumn, an hour repeats, so 01:30 happens twice and a slot keyed on local time will collide with itself. If availability is generated from business hours, generate it from zone-aware arithmetic, not from adding 30 minutes to a local string. This is the same class of problem as scheduled reports across time zones, and it produces the same kind of quiet, once-a-year bug.

Also decide explicitly whose zone wins for the confirmation email. A customer in Berlin booking a London clinic at 14:00 needs both, or the phone call starts at the wrong hour.

External calendars are mirrors, not sources of truth

Syncing with Google Calendar or Outlook is eventually consistent by nature. The push notification arrives late, the full sync happens on a schedule, and a technician can add a personal event directly in the calendar app. Treat the external calendar as an advisory input that reconciles into your own tables, never as the authority that availability is read from live during a booking flow.

That has two consequences worth designing for:

  • Reconciliation must be able to lose. When your system and the external calendar disagree, you need a rule and an audit trail. Usually the external calendar wins for human-added events and your system wins for customer bookings, and either way the conflict produces a notification to a human.
  • Conflict resolution is a product surface. A booking that cannot be honoured needs a reschedule link and an honest message, not a generic error. The notification should expire when the situation resolves, the way any notification tied to work in progress should.

The same instinct applies to retries: if the calendar push fails and the job retries, the retry must not create a second event. That is the duplicate-action problem in payment workflows, wearing a calendar hat.

What to instrument

Double bookings are usually reported by a customer before they are visible in a dashboard, so instrument the flow rather than waiting for complaints:

  • Hold-to-confirm conversion. If holds expire at a high rate, the friction is in the form, not the inventory.
  • Constraint rejections. A rising count on "slot just went" is a demand signal and a scheduling-policy problem, not necessarily a bug.
  • Reschedule and cancellation reasons. Free text here tells you whether the scarcity you are modelling matches reality.
  • No-show rate by slot time and resource. Two double bookings a month is a defect. A no-show pattern concentrated at 08:00 is a policy question.

For a business where appointment booking is the conversion event, these numbers are the difference between a calendar that fills and one that leaks. The booking flow is often the only place a lead becomes revenue, which makes it worth the same engineering rigour as checkout — because functionally, that is what it is.

The short version

Reserve with a persisted, expiring hold. Enforce the slot in the database with a constraint rather than a check. Store instants with zones and generate availability with zone-aware arithmetic. Treat external calendars as reconcilable mirrors. Make every conflict path produce a human-readable outcome.

None of that is exotic. It is the minimum for a booking link that behaves like inventory instead of a suggestion box.

If you are rebuilding a scheduling flow that has started losing slots, or wiring booking into a larger business process automation effort, send us the flow and we will tell you where the race is.

For teams doing this with a dispatch board rather than a calendar, the same slot rules apply — see HVAC and home services for the field-service version of this problem.

Keep reading

Related articles.

New business / 2026

Have a process that should work better?

Bring us the bottleneck, the brittle build, or the idea. We'll give you a direct read on what to do next.