---
title: Concurrency
description: "Each instance processes one message at a time. Slow work goes in concurrent handlers, which run in parallel and report back by sending events."
---

## One message at a time

Every message runs on the instance's [lane](/server/handlers#lanes):
one handler at a time, in arrival order. A handler sees the state every
previous message produced, no other message can race its checks, and
its changes commit before the next message runs.

Lanes are per instance: a thousand vaults process in parallel, each in
order. If one instance gets more traffic than one queue can absorb,
split the identity (per user, per order, per document) instead of
making one instance concurrent.

## Concurrent handlers

A serial handler holds the lane while it runs: if `withdraw` awaits a
two-second bank API, every deposit waits behind it. Keep decisions
serial and move the slow part into a handler wrapped in
`concurrent(...)`. It leaves the lane and runs in parallel with serial
handlers and other concurrent ones. Inside it:

- `ctx.state()` returns a snapshot, which can go stale while the
  handler runs. There is no draft to mutate.
- State changes happen by sending events, whose serial handlers decide
  against current state.

## Reserve, transfer, settle

```ts vault.ts
import { NonRetriableError } from 'experimental-a2'
import { actor, concurrent } from 'experimental-a2/actor'

interface Vault {
  state: {
    balance: number
    /** In-flight transfers: ref to reserved amount. */
    pending: Record<string, number>
  }
  events: {
    deposit: { amount: number }
    withdraw: { amount: number }
    transfer: { ref: string; amount: number }
    settle: { ref: string }
    refund: { ref: string }
  }
}

export const vault = actor<Vault>({
  name: 'vault',
  state: { balance: 0, pending: {} },
  handlers: {
    deposit: (ctx, input) => {
      ctx.state.balance += input.amount
    },

    // the reserve: serial, so the guard sees fresh state
    withdraw: (ctx, input) => {
      if (ctx.state.balance < input.amount) {
        throw new NonRetriableError('insufficient funds')
      }
      ctx.state.balance -= input.amount
      ctx.state.pending[ctx.id] = input.amount
      ctx.send.transfer({ ref: ctx.id, amount: input.amount })
    },

    // the slow part: off the lane, in parallel
    transfer: concurrent(async (ctx, input) => {
      // your slow call; ctx.id makes retried attempts idempotent:
      // const ok = await bank.transfer(input.amount, { idempotencyKey: ctx.id })
      const ok: boolean = true
      if (!ok) {
        ctx.send.refund({ ref: input.ref })
        return
      }
      ctx.send.settle({ ref: input.ref })
    }),

    // finalizers: serial again, idempotent by the pending guard
    settle: (ctx, input) => {
      if (ctx.state.pending[input.ref] === undefined) return
      delete ctx.state.pending[input.ref]
    },
    refund: (ctx, input) => {
      const amount = ctx.state.pending[input.ref]
      if (amount === undefined) return
      delete ctx.state.pending[input.ref]
      ctx.state.balance += amount
    },
  },
})
```

1. **`withdraw` reserves** on the lane, so no other withdrawal can race
   the balance check. The balance drop, the `pending` entry, and the
   `transfer` event commit atomically: the withdrawal cannot
   half-start.
2. **`transfer` does the slow part** off the lane, so deposits keep
   flowing. It never changes state, and it reports failure by sending
   `refund`, not by throwing.
3. **`settle` and `refund` finish.** Both return when `pending[ref]` is
   gone, so a duplicate or late message does nothing. The refund amount
   comes from state.

A crash re-runs only that message from the state it started with, so
the vault always lands reserved, settled, or refunded.

## Buffered sends

`ctx.send` buffers. A serial handler's sends commit with its state
change; a concurrent handler's commit when it finishes. A refusal
discards the buffer with the state changes, so a refused message leaves
no trace. Re-runs produce the same message ids, so a retried handler
never appends its sends twice. `ctx.schedule` is
[not buffered](/actors/timers#scheduling-is-immediate).

## When handlers fail

A concurrent handler that throws retries off the lane, and nothing
waits for it. Serial handlers fail as described in
[Refusals](/actors/introduction#refusals).

`call` works on serial events only. A concurrent handler produces no
answer, so calling one is rejected: `send` those events instead.
