3 min read
Exactly once, from the customer's point of view
Double bookings and duplicate confirmation emails are the same bug in two costumes. Patterns from building a booking platform for a real service business.
Moga Taufiq
Full-Stack & AI Systems Engineer · Moviq
On this page
Hearth & Hair started with a familiar problem: a growing service business losing bookings to phone tag and double-bookings. We built a self-serve platform — two of us, in about three months — where clients book against live availability and get confirmed automatically. It has served real customers since launch.
Two failures would have sunk it on day one, and customers notice both instantly:
- two people booked into the same slot;
- the same confirmation email arriving twice.
They look unrelated. They are the same bug: an operation that must happen exactly once, in a system where requests race and jobs retry.
Double-bookings: never trust what the browser last saw
Two clients open the page and both see 10:00 free. Both click Book. If availability is decided from what each browser last saw, both win.
So availability is computed on the server, from a single source of truth — the database — rather than from client state. Every check goes through the server; that is the price of removing the race.
Server-side is necessary, not sufficient: two requests can still pass the check at the same moment. The strongest place for the guarantee is the database itself. In PostgreSQL, an exclusion constraint makes overlapping bookings for the same resource impossible, no matter how the requests interleave (an illustrative schema, not the production one):
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
resource_id bigint NOT NULL, -- a chair, a stylist, a room…
during tstzrange NOT NULL, -- start/end as real instants
status text NOT NULL DEFAULT 'confirmed',
EXCLUDE USING gist (resource_id WITH =, during WITH &&)
WHERE (status <> 'cancelled')
);
The losing request gets a constraint violation, which the app turns into "That time was just taken" and a fresh set of slots. No locks to reason about, no window to lose.
Duplicate emails: make every send idempotent
Transactional email jobs retry after timeouts — and a duplicate confirmation erodes trust fast. On Hearth & Hair, confirmations and reminders go through a queue, and every send is idempotent.
The key idea: derive an idempotency key from the business event, not from the attempt. Every retry of the same event produces the same key:
// One key per business event — every retry computes the same one.
const key = `booking:${booking.id}:confirmation`
const claimed = await db.query(
`INSERT INTO sent_messages (idempotency_key, status)
VALUES ($1, 'sending')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key`,
[key],
)
if (claimed.rowCount === 0) return // already sent, or being sent
await mailer.send({ to: booking.email, template: 'confirmation', idempotencyKey: key })
await db.query(`UPDATE sent_messages SET status = 'sent' WHERE idempotency_key = $1`, [key])
Be honest about the gap: if the process dies between claiming and sending, that email never goes out. Keeping a status column lets a sweeper retry rows stuck in sending, and many transactional email APIs accept an idempotency key of their own — pass the same one. End to end, "exactly once" is really at least once, plus deduplication.
Reminders: time is the hardest input
The edge cases that needed the most iteration once real bookings flowed were about time: timezone handling, cancellation flows, and reminder timing. Three rules that hold up:
- Store instants, display local time. Keep
timestamptz(UTC instants) in the database and convert to the business's timezone only when rendering. - Key reminders by what they are about. Include the appointment's start time in the reminder's key —
booking:42:reminder:2026-10-03T09:00Z. When a booking moves, the new time produces a new key, and the stale job is recognisably stale. - Check before you send. A reminder job re-reads the booking right before sending and quietly exits if it was cancelled or rescheduled.
A checklist for "must happen once" operations
- Could two requests both succeed? What actually stops them — application code, or a constraint?
- If this job runs twice, what does the customer see?
- Is the idempotency key derived from the business event rather than the attempt?
- What happens to scheduled work when the thing it is about changes?
Putting the platform in front of real users surfaced edge cases no spec ever would have. The patterns above are what made those edge cases survivable.
The project behind this
Dealing with something like this?
I help teams design and ship systems like the ones in these notes. Tell me what you’re working on.
