Read our operating thesis — Machines work better in tandem
JOURNALCORRECTNESS · JULY 2026

Race conditions in agent loops: parallel tool calls and the state you didn't lock

Agents emit parallel tool calls by default, and a language model has no concept of a transaction boundary. Two calls in one turn can write the same row — and the pattern you chose determines which races you get.

WRITTEN BYTandem Machines
Coordinated Intelligence
PUBLISHED
READING TIME17 minutes
SHARE
LinkedInX
KEY TAKEAWAYS
  • 01Parallel tool calls are the default execution model, not an advanced case. Concurrency is opt-out, not opt-in.
  • 02A model has no transaction boundary. Nothing in the loop tells it two calls must not interleave.
  • 03The reasoning step between read and write takes seconds, which makes lost updates far likelier than in ordinary code.
  • 04Optimistic concurrency — version on read, required on write — turns a silent overwrite into a handleable conflict.
  • 05Never hold a lock across a model call. Test against a real database; a mock serialises access and hides the bug.

Why agents race

Agent runtimes issue tool calls concurrently by default: a single assistant turn can contain several tool-use blocks, and the harness executes them in parallel and returns all results together. So concurrency is not an advanced configuration you opt into — it is the normal execution model, and any tool you write is a concurrent tool.

That alone would be manageable. What makes agent concurrency distinctly hazardous is a second property: the model has no concept of a transaction. When it emits three calls in one turn it is not asserting they are independent — it has no vocabulary for saying they must not interleave, and no mechanism for asking.

“Nothing in the protocol lets a model say ‘these two calls must not interleave’. It cannot request a transaction, so it cannot be blamed for not having one.”

And a third property makes the window unusually wide. In ordinary code the gap between a read and a dependent write is microseconds. In an agent the gap includes a model call — the agent reads a record, spends several seconds reasoning about it, then writes. The classic lost-update race has a window thousands of times larger than the one your database was tuned for.

Ordinary serviceAgent loop
ConcurrencyYou chose it, deliberatelyDefault; the harness parallelises
Transaction boundaryExplicit — BEGIN/COMMIT in your codeNone expressible by the caller
Read → write gapMicrosecondsSeconds — a model call sits in the middle
Retry semanticsIdentical request replayedRe-reasoned, possibly different arguments
Who notices a conflictThe code that wrote itNobody, unless the tool reports it
THE THIRD ROW IS WHY AGENT RACES ARE NOT MERELY POSSIBLE BUT LIKELY

Four races you will hit

Four recur in practice: lost update, where two writes clobber each other; double action, where the same effect happens twice; inconsistent read, where two reads in one turn see different worlds; and phantom dependency, where a later call assumes something an earlier call established that has since changed.
RaceShapeWhat the user sees
Lost updateRead A, read A, write A, write AOne person's change silently vanishes
Double actionTwo identical calls in one turnTwo refunds, two emails, two orders
Inconsistent readTwo reads in one turn, state changed betweenThe agent reasons over a world that never existed
Phantom dependencyCall 2 assumes what call 1 sawAn action valid when planned, invalid when executed

The third is the one people do not think of as a race at all, and it is peculiar to agents. If an agent reads a customer’s balance and their order status in two parallel calls, and something changes between them, it now reasons over a combination of facts that was never simultaneously true — and produces a conclusion that is defensible given its inputs and wrong given reality.

Phantom dependency
A later tool call whose correctness depends on a condition established by an earlier call, without anything enforcing that the condition still holds. The agent checked the seat was free, then booked it; between the two, someone else took it. Nothing in the loop revalidates.

Races by agent pattern

Which races you get depends on the pattern you chose. Simple tool-calling loops mostly produce lost updates and double actions. Parallel fan-out adds inconsistent reads. Orchestrator-worker adds cross-worker conflicts. Multi-agent adds the hardest case: two agents with independent contexts acting on shared state.
PatternDominant racesMitigation
Single loop, sequential toolsLost update, double actionVersion-checked writes; idempotency keys
Single loop, parallel tools+ inconsistent readRead a consistent snapshot in one call
Orchestrator-workers+ cross-worker write conflictPartition writes by worker; single writer per record
Multi-agent, shared state+ everything, with no shared contextTreat as distributed systems: explicit ownership
THE RISK CLIMBS WITH THE PATTERN — AND SO DOES THE COST OF FIXING IT LATER

The last row deserves the emphasis. Subagents typically do not share conversation history — each gets a fresh context and only the prompt it was handed. So two subagents working on the same objective have no way to know what the other has already done, and no shared memory in which a lock or a claim could be observed. Coordination has to be external, in the data layer, or it does not exist.

The cheapest structural fix is to give each record a single writer. If exactly one worker type may write orders, cross-worker order conflicts become impossible by construction rather than by discipline — and that is worth designing for before you have many workers.

Optimistic concurrency is the right default

Return a version with every read, require it on every write, and reject the write if the version has moved. The agent receives an explicit conflict instead of silently clobbering someone, re-reads, and retries with current state. This converts an invisible correctness bug into a visible, handleable error.

Optimistic concurrency suits agents unusually well because the expensive part — the reasoning — happens outside any lock, and conflicts are rare enough that paying for a retry when one occurs is cheaper than serialising everything.

Version on read, required on write
// The read hands the agent the version. This is not optional metadata —
// it is the token that makes the subsequent write safe.
const readOrder = tool(
  "read_order",
  "Read an order. Returns a 'version' you MUST pass to update_order.",
  { orderId: z.string() },
  async ({ orderId }) => {
    const row = await db.orders.findById(orderId);
    return { content: [{ type: "text", text: JSON.stringify({
      ...row.data,
      version: row.version,     // ← surfaced to the model deliberately
    })}]};
  },
);

const updateOrder = tool(
  "update_order",
  "Update an order. Requires the 'version' from read_order. If the order " +
  "changed since you read it, this fails and you must read again.",
  { orderId: z.string(), version: z.number().int(), patch: z.record(z.unknown()) },
  async ({ orderId, version, patch }) => {
    const result = await db.orders.updateWhere(
      { id: orderId, version },            // conditional write — the whole trick
      { ...patch, version: version + 1 },
    );

    if (result.rowsAffected === 0) {
      // Not an exception: a structured, actionable result the model can use.
      const current = await db.orders.findById(orderId);
      return { content: [{ type: "text", text: JSON.stringify({
        error: "version_conflict",
        message: "The order changed since you read it. Re-read and reapply.",
        your_version: version,
        current_version: current.version,
      })}], isError: true };
    }
    return { content: [{ type: "text", text: JSON.stringify({ ok: true, version: version + 1 })}]};
  },
);
THE CONFLICT MESSAGE TELLS THE MODEL EXACTLY WHAT TO DO — VAGUE ERRORS PRODUCE VAGUE RETRIES

Two details make the difference between this working and merely existing. The tool description tells the model that the version is mandatory and what to do on failure, because the model only learns the contract from the description. And the conflict result is structured and instructiverather than a bare failure — an agent given “update failed” will try something creative; an agent given “re-read and reapply” will do that.

When you actually need a lock

Use a lock only when the operation cannot be expressed as a conditional write, and never hold one across a model call. An agent’s reasoning step takes seconds; a lock spanning it serialises your system, exhausts connection pools and produces timeouts that look like model failures.

The rule is simple to state and frequently violated: locks may be held inside a tool call, never across one. A tool that acquires a lock, returns to the model for a decision, and expects to still hold the lock on the next call has invented a distributed lock with a multi-second hold time and no lease — which is a well-known way to build an outage.

Right and wrong lock scope
// ✗ WRONG — the lock spans a model call. The agent may take 10s to decide,
//   or crash, or never call release_seat at all.
lock_seat(seat)        → model thinks → book_seat(seat) → release_seat(seat)

// ✓ RIGHT — the entire critical section lives inside ONE tool call.
//   The check and the write are atomic; the model never holds anything.
book_seat_if_free(seat) {
  BEGIN;
    SELECT ... FROM seats WHERE id = $1 AND status = 'free' FOR UPDATE;
    if (not found) { ROLLBACK; return { error: "seat_taken" }; }
    UPDATE seats SET status = 'booked', booking_id = $2 WHERE id = $1;
  COMMIT;
}

// ✓ ALSO RIGHT — no lock at all. A conditional write does the same job.
UPDATE seats SET status = 'booked' WHERE id = $1 AND status = 'free';
// rowsAffected === 0  ⇒  someone else got it. Report that, don't retry blindly.
THE FIX IS ALMOST ALWAYS TO MOVE THE BOUNDARY INSIDE ONE TOOL, NOT TO ADD A LOCK

Notice what the correct versions have in common: the decision and the mutation happen together, in one call, without consulting the model in between. That is the general shape of the fix — collapse the critical section into a single tool rather than trying to coordinate the model across several.

The read-modify-write trap

The most common agent race is also the most innocuous-looking: the agent reads a value, computes a new one from it, and writes the result. Any change in between is silently discarded. It is worse than in ordinary code because the middle step is a model call, so the window is seconds rather than microseconds.

The canonical example is anything cumulative — a balance, a counter, a list of notes, a set of tags.

Lost update, and three ways out
// ✗ The trap. Two agents (or two parallel calls) both read 100.
//   Both compute 100 - 30 = 70. Both write 70. One deduction vanished.
const { balance } = await read_account(id);       // 100
const next = balance - 30;                        // model reasons here — SECONDS
await write_account(id, { balance: next });       // 70

// ✓ 1. Make it a relative operation. The database does the arithmetic.
await adjust_balance(id, { delta: -30 });         // UPDATE ... SET balance = balance - 30
                                                  // no read, no window, no race

// ✓ 2. Conditional write with the version (optimistic concurrency).
await write_account(id, { balance: next, version });   // fails if version moved

// ✓ 3. Conditional on the value you actually saw (compare-and-set).
await write_account(id, { balance: next, expect_balance: 100 });
OPTION 1 IS BEST WHERE IT APPLIES — IT REMOVES THE READ, SO THERE IS NO WINDOW AT ALL

Prefer option 1 wherever the operation is genuinely relative. Agents are strongly drawn to read-then-write because it mirrors how a person would describe the task, so if a relative operation exists it is worth exposing it as its own tool and saying in the description that it should be used instead of read-then-write. Otherwise the model will reach for the pair every time.

Set operations, not list rewrites

The same argument applies to collections. An agent that reads a list of tags, appends one, and writes the whole list back will drop any tag added concurrently. Expose add_tag and remove_tag rather than set_tags, and the race disappears because the operations commute.

Making tools safe by construction

Five rules cover most of it: prefer relative and set operations over read-modify-write, return versions from reads and require them on writes, keep critical sections inside one tool, make mutating tools idempotent under an intent-derived key, and mark read-only tools explicitly so the harness may parallelise them freely.
THE TOOL-DESIGN CHECKLIST
01Relative over absoluteadjust_balance(−30), not write_balance(70)
02Versions on readsSurfaced to the model and required on write
03Critical section inside one callNever hold anything across a model turn
04Idempotent mutationsKeyed on intent, so a retry is not a second action
05Declare read-onlyLets the harness parallelise safely, and documents intent
ALL FIVE ARE DESIGN-TIME DECISIONS — RETROFITTING THEM MEANS CHANGING TOOL CONTRACTS

The fourth is the bridge to a related problem. A race makes two concurrent calls collide; idempotency handles the case where the same call happens twice because the agent retried. Both produce duplicate effects, and they need different mechanisms — see keying on intent rather than the request for why an agent’s retry is not a byte-identical replay.

The fifth is easy to overlook and cheap to add. Marking a tool read-only is not just a hint for the harness — it is a statement in the tool contract that a reviewer can check, and it makes the write surface of your agent enumerable. Anything that is not marked read-only is part of the blast radius.

Testing for races

Force the concurrency; do not wait to observe it. Three tests catch most of it: the same mutating tool invoked twice simultaneously with identical arguments, a read and a conflicting write issued together, and a full turn replayed against a real database. A mocked store serialises access and will pass code that production fails.
Forcing the race
// 1. Simultaneous identical mutation. Exactly one effect must result.
test("double invocation produces one effect", async () => {
  await Promise.all([
    tools.issue_refund({ orderId: "o1", lineId: "l1", amountCents: 500 }),
    tools.issue_refund({ orderId: "o1", lineId: "l1", amountCents: 500 }),
  ]);
  expect(await countRefunds("o1", "l1")).toBe(1);
});

// 2. Stale write must be rejected, not silently applied.
test("version conflict is reported", async () => {
  const a = await tools.read_order({ orderId: "o1" });
  await tools.update_order({ orderId: "o1", version: a.version, patch: { note: "first" } });

  const stale = await tools.update_order({
    orderId: "o1", version: a.version, patch: { note: "second" },   // stale version
  });
  expect(stale.isError).toBe(true);
  expect(await getNote("o1")).toBe("first");     // the first write survived
});

// 3. Run it against the REAL database. This is the test that matters.
//    An in-memory mock cannot fail (1) — it serialises by construction.
IF TEST 1 PASSES AGAINST A MOCK AND FAILS AGAINST POSTGRES, THE MOCK WAS THE BUG

The comment on the third point is the practical lesson and it generalises beyond agents: a concurrency test against a mock tests the mock. Any store that guarantees serial access removes exactly the property you are trying to exercise, which is why this class of bug survives comprehensive-looking test suites and appears in production the first busy afternoon.

One more test worth having, at the agent layer rather than the tool layer: give the model a task that invites parallel calls on the same record and assert on the number of effects. That exercises the harness’s parallelisation, your tool contracts, and the model’s tendency to batch — which is the combination that actually runs.

Finally, the structural point. Most of these races exist because the control flow was handed to a model that has no way to express a transaction. Where the sequence is knowable in advance, writing it as code removes the entire class of problem — which is one more entry in the ledger for building a workflow instead of an agent.

Frequently asked questions

Can AI agents have race conditions?

Yes, and they are easy to hit because most agent runtimes issue tool calls in parallel by default. A model can emit several calls in one turn with no notion of a transaction boundary between them, so two calls in the same turn can read and write the same record concurrently.

Why do agents make parallel tool calls?

Because it is faster and the runtimes encourage it — a single assistant turn may contain multiple tool-use blocks, and harnesses execute them concurrently and return all results together. That is good for latency and it means concurrency is the default execution model rather than an advanced case you opt into.

What is the read-modify-write problem for agents?

An agent reads a value, reasons about it, then writes a new value based on what it read. If anything changes the record in between — another tool call in the same turn, a human, another agent — the write silently overwrites that change. It is the classic lost-update problem, made more likely because an agent's reasoning step takes seconds.

How do you prevent lost updates in agent tool calls?

Use optimistic concurrency control: return a version or ETag with every read, require it on write, and reject the write if the version has moved. The agent then re-reads and retries with current state. This turns a silent overwrite into an explicit conflict the agent can handle.

Should agent tools use database locks?

Rarely, and never held across a model call. An agent's reasoning step can take many seconds, so a lock spanning it will serialise your system and risk timeouts. Prefer optimistic concurrency for records and short, narrowly-scoped locks only for operations that genuinely cannot be expressed as a conditional write.

How do you test for race conditions in an agent?

Force the concurrency rather than hoping to observe it: invoke the same tool twice simultaneously with identical arguments, invoke a read and a conflicting write together, and run the whole turn against a real database rather than a mock. Mocked stores serialise access and will pass code that production fails.

Sources

  1. 01Tool use — parallel tool calls Anthropic, 2026
  2. 02Transaction isolation and concurrency control PostgreSQL, 2026
Tandem Machines
Notes on coordinated intelligence — one essay a month
Subscribe