My Voice Agent Kept Double-Booking Appointments — It Was a Race Condition, Not a Logic Bug
The setup
I built a voice agent for a small clinic-style business that takes appointment bookings over the phone — check availability, confirm a slot, write it to the calendar, done. Standard stuff: speech-to-text into the agent, agent calls a checkAvailability function, then a bookSlot function if the slot's free.
async function bookAppointment(requestedSlot, customerInfo) {
const isAvailable = await checkAvailability(requestedSlot);
if (!isAvailable) {
return { success: false, message: "That slot isn't available." };
}
const booking = await calendarService.createEvent({
slot: requestedSlot,
customer: customerInfo,
});
return { success: true, booking };
}
Worked fine in every manual test. Then it went live and within the first week I had two double-bookings — same slot, two different customers, both with confirmed booking calls in the logs.
Where I went looking first
My first assumption was that checkAvailability was reading stale calendar data — maybe a caching layer serving an old snapshot. I ripped the cache out entirely, hit the calendar API fresh on every check. Still happened. That ruled out staleness and told me the bug wasn't in what the function read — it was in the gap between reading and writing.
What was actually happening
Both double-bookings had near-identical timestamps in the logs — calls that came in within a couple seconds of each other, for the same slot. That was the clue.
checkAvailability and bookSlot were two separate calls, with nothing stopping a second call from checking availability before the first call's booking had actually been written. Call A checks — slot's free. Call B checks a half-second later — also still free, because A hasn't written yet. Both get the green light, both write, and now the slot has two bookings.
Call A: checkAvailability(2pm) → true
Call B: checkAvailability(2pm) → true (A hasn't written yet)
Call A: createEvent(2pm, customerA) → success
Call B: createEvent(2pm, customerB) → success, silently overlapping
This isn't a logic bug in the traditional sense — the code does exactly what it says. The problem is that "check" and "write" aren't atomic, and two concurrent calls can both pass the check before either one writes. Classic race condition, and voice agents make it more likely than you'd think, because a customer calling right after a slot fills, or two calls landing close together during a busy period, is completely normal — not an edge case.
I confirmed it by grepping the logs for booking attempts within a tight time window on the same slot:
const collisions = bookingLogs.filter((log, i) =>
bookingLogs.some((other, j) =>
i !== j &&
log.slot === other.slot &&
Math.abs(log.timestamp - other.timestamp) < 5000
)
);
// collisions.length: 7 out of ~340 bookings that week
Not huge in volume, but every single one of the 7 was a real double-booking. That's a tell — this wasn't noise, it was a consistent pattern under a specific condition: near-simultaneous requests for the same slot.
The fix
Two changes.
1. Move the availability check inside the write, as an atomic operation, instead of two separate calls. Most calendar backends (and definitely anything backed by a real database) support a conditional write — "insert this booking only if no conflicting booking exists" — in a single operation instead of read-then-write.
async function bookAppointmentAtomic(requestedSlot, customerInfo) {
try {
const booking = await db.transaction(async (trx) => {
const conflict = await trx('bookings')
.where({ slot: requestedSlot })
// row-level lock — the exact implementation depends on your DB,
// this is the part that actually closes the race condition
.first();
if (conflict) {
throw new Error('SLOT_TAKEN');
}
return trx('bookings').insert({
slot: requestedSlot,
customer: customerInfo,
}).returning('*');
});
return { success: true, booking };
} catch (err) {
if (err.message === 'SLOT_TAKEN') {
return { success: false, message: "That slot was just booked. Want the next available time?" };
}
throw err;
}
}
The exact locking call I'm leaving out on purpose — it's DB-specific and it's the one piece that actually matters here. If you're hitting this and want the working version for your stack, message us.
2. Give the agent a scripted response for the "slot just got taken" case, since this fix means bookings can now fail after the customer already heard "let me check that for you." Before, the agent had no path for "you were about to get it, but someone beat you to it by two seconds" — so I added an explicit fallback that offers the next available slot instead of a flat rejection:
If a booking attempt fails because the slot was taken between the
check and the confirmation, do not simply say booking failed.
Apologize briefly, explain the slot was just taken, and immediately
offer the next available slot at a similar time. Do not make the
customer ask for alternatives themselves.
Result
Zero double-bookings in the three weeks since, across roughly 900 booking attempts. The 7-in-340 collision rate dropped to 0, and the near-miss cases — where two calls raced for the same slot — now resolve as one successful booking and one clean "just missed it, here's the next slot" instead of two silent successes.
What I took away from this
This one doesn't show up by reading the agent's prompt or reasoning traces, because the agent wasn't reasoning wrong — it followed a perfectly sensible "check, then act" pattern. The bug lived entirely in the timing between two calls, which is invisible unless you're specifically looking for near-simultaneous requests hitting the same resource. If your agent has any action that reads state and then writes based on that state — bookings, inventory holds, seat reservations, anything with a limited resource — "check then write" is not safe on its own. It needs to be one atomic operation, or it will eventually let two requests both win a race that only one of them should.
Comments
No comments yet — be the first.