Originally published on integrationstars.com.
The short version: check shared state (a calendar, a stock count, a CRM record), then act on it, with nothing stopping a second execution from checking in between. An AI step stretches that window from milliseconds to seconds. The fix is to claim before you act, give every business action an idempotency key, and re-check the real system right before the final write. A better prompt, a stricter model or n8n queue mode will not fix it, and queue mode makes races more likely because it adds parallelism.
Why this keeps happening
A popular n8n appointment-booking workflow shared on LinkedIn this month drew the same comment from at least seven different engineers: what happens when two people ask for the same slot at once? The answer, in almost every check-then-act workflow, is that both get it.
n8n runs every incoming request as its own execution, and executions run in parallel. Execution A reads the calendar: 3pm free. Execution B reads the calendar: 3pm free. A books 3pm. B books 3pm. Each step did exactly what it was built to do. The failure lives in the gap between them, which is why it never shows up in testing, where requests arrive one at a time.
Why the AI step makes it worse
In a plain workflow, check and act might sit a few hundred milliseconds apart. Put an AI Agent node between them (reading the request, deciding which slot fits, calling a tool, maybe confirming with the user) and that gap grows to several seconds, sometimes much longer when there's a conversation in between. The probability of a collision grows with that window. An agent that works flawlessly in a demo can double-book within its first busy hour.
Agents also add their own duplicates: a model may call the same booking tool twice in one run, or a timeout makes the workflow retry a call that actually succeeded. Neither is the model "making a mistake" in a way a better prompt fixes. They're ordinary distributed-systems problems arriving through a new door.
Where duplicate and parallel runs come from
- Simultaneous requests: two users, two webhook calls, two executions, same resource
- Webhook redelivery: senders like Stripe, and most webhook providers, resend an event if they don't get a fast enough response
- Retry On Fail: n8n's node-level retry re-runs a step after a timeout, even if the first attempt actually went through on the other side
- Agent tool calls: the model calls the same tool twice, or a second agent run picks up the same task
- Queue mode with multiple workers: more executions in parallel by design
The fix: claim first, key every action, re-check before writing
1. Claim the resource before acting on it
Instead of "check, then book", the workflow first tries to claim the slot in a store that refuses a second claim. Only the execution that wins the claim continues. In Postgres that's a single insert against a unique constraint:
INSERT INTO slot_claims (slot_id, execution_id)
VALUES ('2026-10-08T15:00', 'EXECUTION_ID')
ON CONFLICT (slot_id) DO NOTHING
RETURNING slot_id;
(In n8n, EXECUTION_ID is the expression for the current execution's id.)
If a row comes back, this execution owns the slot. If nothing comes back, someone else got there first, so route to a clean "that time was just taken" response. A Redis key set only if it doesn't already exist does the same job. What matters is that the database or Redis makes the decision atomically, not an IF node comparing two values that may already be stale.
2. Give every business action an idempotency key
Build one ID per real-world action (the booking request ID, the order ID, the webhook event ID), record it before the side effect, and check it first on every attempt. When the webhook resends, the retry fires, or the agent calls the tool again, the second attempt sees the key is already done and stops instead of booking or charging twice. Where the downstream API accepts an idempotency key of its own (Stripe does), pass it through as well.
3. Re-check the real system right before the final write
Your claim table protects against other runs of this workflow. It doesn't know about someone booking the same slot directly in the calendar app. A last read immediately before the write, and a clear failure path if the slot is gone, covers what the claim can't.
Let the agent decide, not commit. The cleanest structure keeps the AI out of the critical section entirely: the agent figures out what the user wants, then hands a structured request to a plain, deterministic sub-workflow that does claim, re-check, book. The slow, unpredictable part stays outside the window where races happen.
When the booking fails: release the claim, confirm the key
Added October 15, 2026, after a reader pointed out that the fix above leaves two failure paths open.
The claim and the idempotency key both prevent duplicates, but each can leave a stuck record if the booking call fails or times out after them. Close both.
Give the claim a status and a clock
If an execution wins the claim and the calendar call then fails, the plain insert above leaves the row behind and the slot is blocked for everyone until someone deletes it by hand. Add a status and a timestamp, and let a lost claim be taken again:
CREATE TABLE slot_claims (
slot_id text PRIMARY KEY,
execution_id text NOT NULL,
status text NOT NULL DEFAULT 'claimed', -- claimed, booked, released
claimed_at timestamptz NOT NULL DEFAULT now()
);
-- claim: succeeds on a new slot, or on a slot that was released
INSERT INTO slot_claims (slot_id, execution_id)
VALUES ('2026-10-08T15:00', 'EXECUTION_ID')
ON CONFLICT (slot_id) DO UPDATE
SET execution_id = EXCLUDED.execution_id, status = 'claimed', claimed_at = now()
WHERE slot_claims.status = 'released'
RETURNING slot_id;
Then use the status on every path. When the booking succeeds, set it to booked. When it fails, the error path sets it to released so the slot comes back. For claims that never reached either (the workflow crashed, or the call hung), run a scheduled cleanup that frees claims still claimed after longer than a booking should take. One caution: a timeout does not prove the booking failed, so check the real calendar before releasing, or you will free a slot that was in fact booked.
Record the key as pending, mark it done after confirmation
The mirror problem: if the key is recorded before the side effect and the side effect fails, the retry sees the key and stops, and the booking is lost without a trace. Record the key as pending first and mark it done only after the API confirms. A retry then behaves by what it finds:
- Key is done: stop, the action already happened.
- Key is pending and recent: another run is working on it, so wait or stop.
- Key is pending and old: check the real system for the booking first. If it exists, mark the key done. If not, retry the action.
How to test for it before customers do
Fire the same request at the webhook twenty times at once (a short script or a load-testing tool does it in seconds) and count the bookings. One is the right answer. Then resend a single webhook event three times and check that nothing happens twice. Then test the failure path: kill the booking call halfway (a timeout or an error injected after the claim) and check that the slot comes back and that a retry neither double-books nor loses the booking. All three tests take minutes and catch failures that ordinary click-through testing never will.
Need this built?
Entech Solutions builds n8n agent workflows that hold up under real traffic: claim-first concurrency, idempotent side effects, and load tests before launch. See our n8n AI and agentic workflow design page, our guide to n8n error handling, or get in touch to review a workflow you already run.
Top comments (3)
The claim-first insert is the right shape, and keeping the agent outside the critical section is the part I would copy. Two failure paths are worth adding. First, the claim has no exit. If an execution wins the slot_claims insert and the calendar call then fails or times out, the row stays and the slot is blocked for everyone until someone deletes it by hand. A status column (claimed, booked, released) plus claimed_at lets the error path release it, and a scheduled cleanup can free claims that never reached booked. Second, the idempotency key has the mirror problem. If the key is recorded before the side effect and the side effect fails, the retry sees the key and stops, so the booking is lost without a trace. Recording it as pending, marking it done only after the API confirms, and having a retry that finds an old pending entry check the real system first closes that gap. Both are easy to add to the load test: kill the booking call halfway and see whether the slot comes back.
Thanks, this is a fair catch and it's missing from the post. Both failure paths are real.
Claim with no exit: we should have shown a status (claimed, booked, released) and a claimed_at timestamp, so the error path can release the slot and a scheduled cleanup can free claims that never reached booked. Without that, one failed calendar call blocks the slot for everyone.
Idempotency key: recording it before the side effect has the mirror problem. Recording it as pending, marking it done only after the API confirms, and having a retry that finds an old pending entry check the real system first closes that gap.
Killing the booking call halfway and checking that the slot comes back is a great addition to the load test. I'll add a section on both failure paths to the original article.
Glad it helps. One detail for that section: the cleanup should only free claims older than the calendar call's own timeout plus some margin, otherwise it can release a slot whose booking is still in flight and recreate the race it was meant to clean up after.