DEV Community

Kynth
Kynth

Posted on • Originally published at receptionist.kynth.studio

Booking a slot over the phone without double-booking the truck: the race condition nobody warns you about

A plumber I know keeps his phone face-down on the passenger seat. Not because he doesn't want the work — he is the work — but because when it rings, he's usually under a sink with both hands wet, or up a ladder, or elbow-deep in a job that pays exactly nothing extra if he stops to answer. So it rings out. And the person on the other end doesn't leave a voicemail. They just call the next name on the list.

That missed ring is a booked job walking to a competitor. He knows it. He can't do anything about it. That's the dread.

So we built a thing that answers every call, hears the job, quotes it, and books a crew. Sounds like a chatbot with a phone number. The hard part turned out to be one sentence: book a crew into the slot without ever double-booking a truck.

Why this is a real problem, not a CRUD write

Voice calls are slow and concurrent. A call lasts 90 seconds. During those 90 seconds the caller is deciding, the AI is talking, and — critically — other calls are happening on other lines. Two callers can both be told "Thursday 2pm is open" because at the moment each was quoted, it was open. Neither has confirmed yet. Both say yes. Now one truck, two jobs, and a customer who took the afternoon off for nobody.

The naive version looks fine in every demo and breaks the first busy Monday:

// DON'T: read-then-write with a human-length gap in between
const slot = await db.findOpenSlot(crew, when);   // t=0s, both calls see it open
await speak(`I've got you Thursday at 2. Sound good?`);
// ...15 seconds of the caller saying "uh, let me check with my wife"...
await db.book(slot, job);                          // t=18s, both calls write. collision.
Enter fullscreen mode Exit fullscreen mode

The gap between quote and confirm is filled with human speech. You cannot hold a transaction open for 18 seconds of "let me check with my wife."

The fix: hold, don't book

We split the single write into two phases with a short-lived reservation between them. Quoting a slot places a hold with a TTL. Confirming promotes the hold to a booking, but only if the hold is still yours.

// Phase 1 — at quote time: claim a hold, atomically
const hold = await db.query(`
  INSERT INTO holds (slot_id, call_id, expires_at)
  SELECT $1, $2, now() + interval '90 seconds'
  WHERE NOT EXISTS (
    SELECT 1 FROM holds    WHERE slot_id = $1 AND expires_at > now()
    UNION
    SELECT 1 FROM bookings WHERE slot_id = $1
  )
  RETURNING id;
`, [slotId, callId]);

if (!hold.rows.length) return offerNextSlot();  // someone holds it — quote a different time
Enter fullscreen mode Exit fullscreen mode

The WHERE NOT EXISTS runs inside the insert, so the check and the claim are one atomic step. Two concurrent calls race for the same row; the database picks a winner. The loser never even quotes 2pm — it's already moving on to 2:30 before the caller notices a pause.

Phase 2, at "yes," is a conditional promote: INSERT INTO bookings ... WHERE hold still valid AND owned by this call. If the caller rambled past the TTL, the hold's gone, the promote fails, and the agent gracefully re-quotes instead of silently overwriting someone.

The parts that bit us

  • TTL tuning is a UX knob, not a config value. Too short and slow talkers lose their slot mid-sentence; too long and you artificially block a slot while someone hangs up on you. We settled on releasing the hold the instant a call ends, not just on timeout — a dropped call shouldn't freeze a truck's calendar for 90 seconds.
  • "One truck" isn't one row. A crew has drive time. Booking 2pm across town when the 1pm job is 40 minutes away is a double-book in disguise. The hold has to reserve the travel envelope, not just the appointment box.
  • Idempotency on retries. Phone networks retry webhooks. The promote had to be safe to run twice — same call_id, same slot, second attempt is a no-op, not a second booking.

None of this is exotic. It's the boring, correct version of a reservation system — the same trick airlines and ticket sites use — applied to a domain where the "user" is a synthesized voice negotiating in real time with someone who won't stop talking about their water heater.

That reservation engine is the thing that lets Kynth Receptionist answer the plumber's phone so he doesn't have to leave it face-down anymore.

Hear it take a call → https://kynth.studio/l/receptionist

Top comments (0)