---
title: Guarantees
description: Appends are effectively-once, handlers are at-least-once, and retries keep event identity across crashes.
---

## In short

- **Appends are effectively-once.** Event ids deduplicate writes. A batch commits
  before its handlers start; they may start before `append` resolves.
- **Handlers are at-least-once.** They may run again after a crash, so
  [side effects use idempotency keys](/server/handlers#handlers-can-run-twice).
- **The log stays ordered.** Every session has one immutable append order.
- **Handlers are concurrent by default.** Log order does not serialize them.
  Only events in the same lane run one at a time in log order.

## One event, many attempts

An event is pending until A2 marks it processed. Every wakeup (an append, a
scheduler watchdog, or `server.drain()`) claims the session's eligible events,
and the log decides what remains. Reads never dispatch handlers.

Each claim expires. If its holder dies, the claim lapses and the next wakeup
claims the event again with a larger `ctx.attempt`. The retry gets the same
event id, index, type, and payload. A kill after the claim can use up an
attempt before your code runs, and that attempt counts toward the event's
[failure budget](#when-a-handler-keeps-failing). An older attempt can never
overwrite a newer one's completion or failure.

:::note[Recovery is not an event]
A2 does not append `recovered` or `continued`. Recovery retries the same event.
Your log contains only application facts.
:::

## Immediate and dependent events

[`ctx.session.append(name, ...events)`](/server/handlers#append-what-should-happen-now)
commits immediately: its events stay in the log if the parent later crashes,
and run concurrently with it unless both share a lane.
[Returned events](/server/handlers#return-what-happens-after-success) commit
with the parent's completion, in one transaction.

If the handler throws or dies before that transaction, neither the completion
nor the returned events exist, and the retry produces the same deterministic
ids. If the transaction commits but its acknowledgement is lost, the retry gets
the committed rows back. A partial or changed batch is rejected.

The transaction covers only A2's log. An HTTP call, database write, or other
external effect can't join it, so a crash after the effect but before
completion repeats it. Use `event.id` as an idempotency key or add fencing in
the external system.

## Lanes

[Lanes](/server/handlers#lanes) are session-scoped serial groups. Only the
lowest-index unfinished event in a lane is eligible, so a failure blocks later
events in that lane, not the whole session. Unlaned events and different lanes
run concurrently. A2 stores the lane at append, so a changed lane resolver
affects only new events.

A lane controls A2 dispatch, not the outside world. A2 claims every eligible
event, with no global concurrency limit or priority queue. Put capacity limits
near the constrained resource, such as an API client, connection pool, or
runtime.

## Append and scheduling

A top-level append commits before any wakeup. A2 then starts the handlers
inline and, with a scheduler, arms a watchdog in parallel. Handlers never wait
for the queue, and a handler may start before `append` returns. `append` waits
up to two seconds for the watchdog send, never for handlers. A failed arm
records `a2.append.armed = false` on the trace. Handler appends and returned
events join the running drain and add no arm.

[`append.dispatch`](/schedulers/overview#dispatch-in-a-fresh-invocation) skips
the inline drain and hands the work to the scheduler instead.

An event type without a handler settles in the append transaction. It starts
no drain or arm unless older handled work in the session is still pending.

## Causal trees are durable

Every event a handler appends or returns stores a `cause`: the triggering
event's index and dispatch attempt. Top-level appends store `null`. Children
of one handler are siblings, nested handlers add levels, and a retried child
keeps its original cause.

## A chain that crosses the function timeout

A function killed mid-handler restarts that handler from its entry point in a
fresh invocation: same event, new attempt. Events already processed stay
processed, so a chain of handlers can outlast one invocation, but each handler
must fit in a fresh one: an attempt that runs out of time is a failed attempt,
and a handler that never fits dead-letters after ten. Split longer work into
smaller events or increase the function duration. On Vercel, A2 caps the claim window
at the function deadline when the platform exposes it, so a retry starts
promptly after a timeout. Once the deadline passes, A2 claims nothing more in
that invocation, so a timeout costs the event one attempt.

## When a handler keeps failing

A caught failure counts against that event, releases its claim, and leaves
other eligible events running. A2 holds it back for a delay that starts at
one second and doubles with each failed attempt up to 30 seconds, then retries
it on the session's next wake. With a scheduler, that's a few seconds at first,
then the delay itself. An attempt that ends before it finishes, because its
process timed out, crashed, or lost its claim, counts too, once the next
attempt takes its claim over. Ten failed attempts dead-letter the event: later
events in its lane stay blocked, while unlaned events and other lanes continue.
A crash costs every event it interrupts one attempt, so a session whose work
keeps outrunning its function dead-letters: bound its fan-out, or put work that
waits on the same resource in one lane. A `NonRetriableError`, or a returned
batch the store refuses because it reuses an event id, dead-letters the event
on that first attempt instead: a retry would fail the same way. The event
waits until you
[retry or skip it](/server/handlers#resolve-a-dead-letter) with
`session.redrive()`.

## Limits

| Situation | Result |
| --- | --- |
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
| No scheduler configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
| Scheduler invocation dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
| Store backend unavailable | Safe progress stops until it returns. |
| External effect succeeds before A2 completion | The handler may repeat it. Use idempotency or external fencing. |

## What append never throws for

A failing handler never makes `append` throw. Handler failures are retried and,
if persistent, [dead-letter the event](#when-a-handler-keeps-failing).

Append success means the event is durable, not that its handlers finished. An
identical batch retry returns rows with the same ids. A mixed batch throws
[`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
