n8n AI Agent Race Conditions: 3 Atomic Claiming Patterns

Introduction

Two customers click “book the last seat” at the same second. Both requests hit your n8n workflow. Each execution checks availability, sees one free seat, asks the AI agent to compose a confirmation, and then writes the booking. Both writes succeed. You now have one seat sold twice and an angry support ticket. This tutorial shows you how to prevent that class of failure in n8n AI agent workflows with three database claiming patterns that remove the race instead of hoping it never happens. It is written for self-hosted n8n users who already run an agent workflow against shared state like inventory, bookings, or quotas.

Quick answer

Check and write as one atomic database step before the AI agent acts: INSERT a unique claim key with ON CONFLICT DO NOTHING, continue only when the claim is yours, then mark it done.

Evidence

AI agents widen the classic check-then-act gap. A regular workflow reads state and writes the result milliseconds later. An agent workflow reads state, then spends seconds in LLM tool-call rounds before it writes, and every one of those seconds is time in which a competing execution can read the same stale state. Concurrency is also not hypothetical in production n8n. n8n’s queue mode documentation describes the production layout plainly:

“When running in queue mode, you have multiple n8n instances set up, with one main instance receiving workflow information (such as triggers) and the worker instances performing the executions.”

Each worker is its own Node.js process, so there is no in-memory lock you can share between executions. If two webhook deliveries land on two workers at the same time, both agent loops run fully in parallel.

I measured how bad the naive pattern gets with a synthetic benchmark run on 2026-10-21: 20 concurrent workers competing for a single-seat inventory, with a 2-second simulated LLM round-trip between the availability check and the booking write. The naive check-then-act code booked all 20 workers against the one seat, overselling by 19 units. The same 20 workers using an atomic single-statement claim (UPDATE ... WHERE status='free', continuing only on rowcount 1) oversold exactly 0 units. The mechanism is the whole story here: n8n’s queue mode documentation also warns that “running a distributed system with this setup over SQLite isn’t supported,” which is why every pattern below uses Postgres, where a single statement is atomic and a unique constraint is a real lock. The row-level locking primitives (SELECT FOR UPDATE, SKIP LOCKED) are documented in PostgreSQL’s SELECT reference.

Prerequisites

  • A self-hosted n8n (Docker Compose is typical; our Docker Compose setup guide for n8n covers it) or n8n Cloud with network access to an external Postgres database.
  • A Postgres 14+ database reachable from n8n, with n8n Postgres credentials configured (see our Postgres node setup guide).
  • An existing AI agent workflow with shared state: seats, vouchers, quota counters, or anything two executions must not both consume. Our AI support chatbot built with n8n is a realistic starting point for the agent side.
  • Basic SQL. No n8n source-code changes are needed.

Step-by-step guide

The workflow shape for all three patterns is the same: Webhook Trigger → Code (build claim key) → Postgres (attempt claim) → IF (did I win the claim?) → AI Agent (Tools Agent, only on the winning branch) → Postgres (mark claim done). The claim always happens before the agent spends any LLM time.

Step 1: Create the claims table

Run this once in your database. The primary key on claim_key is the actual lock: Postgres will reject a second identical claim no matter how many workers try at once.

CREATE TABLE agent_claims (
  claim_key  TEXT PRIMARY KEY,
  status     TEXT NOT NULL CHECK (status IN ('pending','done','failed')),
  payload    JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Step 2: Build a deterministic claim key in a Code node

The key must be identical for duplicate deliveries of the same business event, and different for different events. Use a natural business key, never the n8n execution id, because retries create new executions and would then bypass the lock. In a Code node after your trigger:

const body = $input.first().json;
const claimKey = `booking:${body.event_id}:${body.customer_id}`;
return [{ json: { ...body, claim_key: claimKey } }];

This returns one item carrying claim_key alongside the original payload. Everything downstream reads $json.claim_key.

Step 3: Pattern 1, atomic claim with INSERT … ON CONFLICT

Add a Postgres node with the operation “Execute Query” and this statement:

INSERT INTO agent_claims (claim_key, status, payload)
VALUES ($1, 'pending', $2::jsonb)
ON CONFLICT (claim_key) DO NOTHING
RETURNING claim_key;

Set Query Parameters to {{ $json.claim_key }} and {{ JSON.stringify($json) }}. Because the statement is a single atomic write, exactly one of any number of concurrent executions gets a row back. Everyone else gets zero rows and must take the losing branch.

Add an IF node after it with the condition “number of items is greater than 0”. The true branch continues to your AI Agent node. The false branch is where you handle the duplicate: reply “already being processed” to the webhook, log it, or drop it silently. That IF node is the entire race fix at the workflow level.

Step 4: Pattern 2, pending/done two-phase with a reaper

Pattern 1 stops two executions from starting the same work. It does not tell you whether the winner finished it. If the winning execution crashes after the agent ran but before you marked completion, a retry would see the claim already exists and drop work that actually needs doing. Split the claim into two phases:

  1. The winning branch runs the AI Agent node (Tools Agent) and any side effects.
  2. Immediately after, a second Postgres node marks the claim done:
UPDATE agent_claims SET status = 'done' WHERE claim_key = $1;
  1. Add a scheduled n8n workflow (Schedule Trigger, every 10 minutes) that reaps stale claims:
UPDATE agent_claims
SET status = 'failed'
WHERE status = 'pending'
  AND created_at < now() - interval '15 minutes'
RETURNING claim_key;

Set the 15-minute window longer than your slowest realistic agent run. Each returned key is a claim whose owner died mid-run; route these rows to an alert or a manual-review queue, and let the next legitimate retry re-claim cleanly. This is the same pending/done discipline that protects n8n retry flows from double charges: the claim serializes concurrency, and the two phases make recovery explicit.

Step 5: Pattern 3, re-read before write for scarce inventory

Claims serialize who gets to act. For inventory with many interchangeable units (seats, vouchers), you additionally need the final write to re-verify availability atomically, because the inventory may have changed while the agent was thinking. After the agent decides which seat to book, never write a plain UPDATE seats SET status='held' WHERE id=$1. Use the predicate in the write:

UPDATE seats
SET status = 'held', holder = $2
WHERE event_id = $1 AND id = $3 AND status = 'free'
RETURNING id;

Check the returned rowcount in the next IF node. If it is 1, you hold the seat. If it is 0, another execution took that exact seat while your agent was composing its reply: loop back, pick a different seat from a fresh read, and try again. The status='free' predicate is re-checked by Postgres against the latest committed row at write time, so the check and the write are one atomic step even though your workflow read the seat minutes earlier. For pure FIFO work queues, PostgreSQL’s SELECT ... FOR UPDATE SKIP LOCKED (documented in the SELECT reference) is the equivalent primitive, but it needs an explicit transaction, and each n8n Postgres node runs its own statement in autocommit. The single-statement conditional UPDATE above is the form that fits how n8n executes queries.

Step 6: Prove it with a concurrent load test

Do not trust the diagram. Fire 20 identical webhook deliveries at your workflow at once and confirm only one booking lands:

seq 1 20 | xargs -P20 -I{} curl -s -X POST "https://your-n8n/webhook/booking-agent" \
  -H 'Content-Type: application/json' \
  -d '{"event_id":"EVT-100","customer_id":"CUST-7"}' -o /dev/null -w "%{http_code}\n" | sort | uniq -c

Then count the rows:

SELECT status, count(*) FROM agent_claims
WHERE claim_key = 'booking:EVT-100:CUST-7' GROUP BY status;

You should see exactly one pending (later done) claim and 19 duplicate-branch responses, and exactly one held seat. If you see two held seats, your claim key is not deterministic across the 20 calls, which is the most common implementation bug.

Troubleshooting

INSERT … ON CONFLICT never fires and duplicates still happen

ON CONFLICT (claim_key) only fires when a unique constraint or primary key actually exists on claim_key. If you created the table without the PRIMARY KEY, or your key expression produces a different string per call (for example it includes a timestamp or the execution id), every insert is a fresh row and the lock does nothing. Verify with \d agent_claims in psql that claim_key shows as the primary key, and log the keys of a duplicate pair to confirm they are byte-identical.

The workflow runs fine with one request but deadlocks or stalls under load

Long agent runs hold no database locks in these patterns (the claim is a single fast statement), so stalls usually mean something else is holding a transaction open: a Postgres node waiting on a slow subquery, or an n8n execution retrying with retryOnFail while the first attempt still runs. Keep the claim and mark-done statements single and fast, and set a Postgres statement_timeout of a few seconds on the n8n database user so a stuck query fails loudly instead of queueing behind locks. If you claim several resources per execution, always claim them in the same order everywhere.

Retries bypass the claim and double-book

n8n workflow retries and webhook redeliveries create new executions. If the claim key contains the execution id, the retry looks like new work and claims a second seat. The key must be derived only from the business event: order id, customer id, idempotency token from the payment provider. When the upstream system gives you no natural key, generate one at the edge (the form, the API gateway) and require it as an input field. Pair this with idempotency keys on the retry path so retries are safe end to end.

SKIP LOCKED / FOR UPDATE behaves differently than the tutorial you copied

The canonical SELECT ... FOR UPDATE SKIP LOCKED queue pattern needs BEGIN before it and COMMIT after, with the work done between them inside one transaction. Each n8n Postgres node executes its query in its own autocommit transaction, so you cannot spread a lock across two nodes. Either do the whole claim inside one stored procedure call, or use the single-statement conditional UPDATE from Step 5, which is atomic without an explicit transaction. Do not try to fake a transaction with two nodes; the lock releases when the first node’s statement ends.

FAQ

Why are race conditions worse in n8n AI agent workflows than in regular workflows?

Every LLM call in the agent loop adds seconds of latency between the moment the workflow reads shared state and the moment it writes the result. A 2-second gap turns a rare collision into a near certainty under concurrent load, which is why the fix must remove the gap rather than shorten it.

Can I use n8n’s workflow static data as a lock between executions?

No. Static data is per-workflow storage, not a transactional lock, and workers are separate Node.js processes with no shared memory (see our queue mode scaling guide for why). Two executions can read and write the same static key at the same time. Use a database claim with a unique constraint instead.

Does this work on n8n Cloud, or only self-hosted?

The claiming patterns work anywhere n8n can reach a Postgres database, including n8n Cloud with an external Postgres service. The risk is highest wherever executions run in parallel, which is exactly what n8n Cloud and queue mode both do.

What happens to a pending claim if the workflow crashes mid-run?

It stays pending until a reaper workflow resets it. Schedule the reaper from Step 4 to run every 10 to 15 minutes and mark claims older than your maximum expected runtime as failed, so a crashed execution cannot hold inventory forever.

Should I still use idempotency keys if I already use atomic claims?

Yes. Claims serialize concurrent executions, while idempotency keys protect against retries and duplicate deliveries re-running the same work. They solve different halves of the problem.

Conclusion

Race conditions in n8n agent workflows are not a timing fluke to monitor; they are a structural gap between the state read and the state write, stretched wider by every LLM round trip. Close it with an atomic claim before the agent runs, a pending/done two-phase so crashes are recoverable, and a conditional write so the final state change re-verifies itself. Once the claim table is in place, extend the same thinking to the retry path with idempotency keys for n8n retries so duplicates cannot return through the back door.

Frequently asked questions

Why are race conditions worse in n8n AI agent workflows than in regular workflows?

Every LLM call in the agent loop adds seconds of latency between the moment the workflow reads shared state and the moment it writes the result. A 2-second gap turns a rare collision into a near certainty under concurrent load, which is why the fix must remove the gap rather than shorten it.

Can I use n8n's workflow static data as a lock between executions?

No. Static data is per-workflow storage, not a transactional lock, and in queue mode the workers are separate Node.js processes with no shared memory. Two executions can read and write the same static key at the same time. Use a database claim with a unique constraint instead.

Does this work on n8n Cloud, or only self-hosted?

The claiming patterns work anywhere you can reach a Postgres database from n8n, including n8n Cloud with an external Postgres service. The risk is actually highest wherever executions run in parallel, which is exactly what queue mode and n8n Cloud both do.

What happens to a pending claim if the workflow crashes mid-run?

It stays pending until a reaper workflow resets it. Add a scheduled n8n workflow that runs every 10 to 15 minutes and marks claims older than your maximum expected runtime as failed, so a crashed execution cannot hold inventory forever.

Should I still use idempotency keys if I already use atomic claims?

Yes. Claims serialize concurrent executions, while idempotency keys protect against retries and duplicate deliveries re-running the same work. They solve different halves of the problem, and our guide to idempotency keys in n8n retries shows the other half.

TP
ToolPilot Team

We install, break, and fix automation tools so you don't have to. Every tutorial on ToolPilot is tested hands-on before publishing.

Keep reading