---
title: Idempotent writes and retries
description: Make a mutation safely retryable with idempotencyKey, and what happens when you don't.
---

A write's ack can go missing for reasons that have nothing to do with whether the write landed: a dropped connection, a timeout, a node that died after committing but before replying. Retrying blind then has two ways to be wrong:

- **Don't retry**: a write that never landed is silently lost.
- **Retry unconditionally**: a write that did land gets applied twice.

`idempotencyKey` is how you tell the system which case you're in.

## Unkeyed mutations are never retried automatically

Neither the engine's client nor the router will retry a bare mutation. A transport failure on `mutate.update` might mean the request never reached a node, or it might mean the node applied it and only the reply was lost. Without a key there is nothing to tell those apart.

Retrying risks a double-apply; not retrying risks silently dropping the write. The client reports `TransportFailed` and leaves the decision to you.

### Reads always retry

Reads and other side-effect-free calls (`query.execute`, `changes.read`, `node.stats`) don't have this problem. They retry automatically, key or no key.

## Passing a key

**Engine**

```ts
await engine.update("acme_roadmap", "task-1", { status: "done" }, {
  idempotencyKey: crypto.randomUUID(),
});
```

**HTTP client**

```ts
await db.update("task-1", { status: "done" }, {
  idempotencyKey: crypto.randomUUID(),
});
```

`engine.transaction` accepts the same option, and keys the whole multi-board unit as one: a retried transaction dedupes as a single thing, not per statement.

Keys require `writePath: "wal"`. There is nowhere to record a key under `writePath: "lake"`, and silently not deduplicating would turn a caller's retry into a double-apply, which is worse than refusing:

```ts
// on an engine running writePath: "lake"
const refused = await engine.update("b", "i1", { n: 1 }, { idempotencyKey: "k" });
refused.isErr() && refused.error.message; // '...requires writePath "wal"...'
```

## Exactly-once semantics

A key is deduplicated in the WAL itself: the first append with a given key is applied and its watermark recorded against that key; every later append carrying the same key is answered with that **original** watermark and applies nothing.

A replay also fires **no change events**. `onChange` listeners and `watch` saw the write once, when it first landed, and a retry that applied nothing has nothing new to report. See the [watch guide](/guides/watch) for what does fire.

```ts
const first = (await engine.update("b", "i1", { n: 2 }, { idempotencyKey: "k1" })).unwrap();
const retry = (await engine.update("b", "i1", { n: 2 }, { idempotencyKey: "k1" })).unwrap();
retry === first; // true: the same watermark, nothing applied twice
```

## Keys expire after 24 hours

Idempotency keys are retained for a bounded window, expired by the same maintenance tick that runs the WAL flusher. A retry that arrives after its key has expired is treated as new and re-applies: deduplication applies only within the retention window. Retry soon after the original attempt, not days later.

## Through the router: retry and failover

A keyed mutation is safe not just to retry but to send to a _different_ node: since a replay is answered with the original watermark regardless of which node's WAL the request reaches, the router is free to fail a request over after a lost reply.

The client marks this by setting the `x-lakefront-idempotent` header exactly when the request body carries a key, so the router can decide without parsing bodies.

A bare mutation gets one attempt. If the reply is lost, the router returns `502` without retrying. The mutation may already have committed. Header and envelope details are in the [wire protocol reference](/reference/wire-protocol).

## Choosing keys

Generate keys client-side. A node has no way to invent one that survives past the request that would need it. A key should be stable per logical operation: the same user action, retried, reuses the same key; a genuinely new action gets a new one.

```ts
// generated once, when the user clicks: reused across every retry of this click
const idempotencyKey = crypto.randomUUID();

async function submit() {
  const result = await db.update("task-1", { status: "done" }, { idempotencyKey });
  if (result.isErr()) {
    // retry with the same key: a resend of this click, not a new one
  }
}
```

Keys are capped at 200 characters.
