What happens to your retries when the server dies

Two copies of the same checkout, running side by side. Both wait: for a card that keeps declining, for a warehouse that takes its time, and — when the order cannot be filled — for a refund to clear. Pick a scenario, then press the red button to destroy the machine they are both running on. One of them finishes anyway.

A · The ordinary way

attempt last progress payment

B · With demlik/tea

attempt last progress payment

Settles: the card clears on the fourth try, the warehouse has stock, the order completes. Gets refunded: the card clears sooner, but the warehouse is out of stock — so the money that was already taken has to be given back. Between them the order walks all six states. Each start mints a fresh order id so every run begins on a clean Durable Object; Reset wipes the id in the box.

How this works

What the two lanes actually are

Both lanes run the same three steps — take the payment, reserve the stock, settle — and both talk to the same fake card processor, which declines the first three attempts and then accepts.

Lane A keeps its retry ladder in memory. Which attempt it is on is a loop variable, and the wait before the next attempt is a sleep inside a function that is still running. It writes a status row to storage so a dashboard can read it, but the row is a report about the work, not the work itself.

Lane B keeps the ladder in its state: the attempt number and the timestamp the next attempt is due are ordinary fields, and every change to them is saved to Durable Object storage before anything else happens. The wait is not a sleep — it is a Durable Object alarm set for that timestamp. Nothing is holding the retry in memory, because nothing needs to.

What the kill button really does

It calls ctx.abort() inside the Durable Object. That is not a simulated failure or a thrown error — it destroys the isolate immediately. Running functions do not get to finish, pending timers never fire, and nothing gets a chance to clean up or flush.

Lane A loses the only thing that was going to continue the order, so its status row stays at "retrying…" forever. Lane B loses nothing that mattered: the state was already saved and the alarm was already registered with the platform. When the alarm comes due, the platform starts a brand new isolate, that isolate loads the saved state back through the Store, and the saga carries on from the attempt it was on.

This is why the two lanes diverge from one button press. The difference is not reliability engineering — it is where the retry was written down.

The worst moment to be killed is during the refund. By then the customer has been charged and the order is known to be unfillable, so the only thing left is the obligation to give the money back. In lane A that obligation exists as a function that is currently sleeping, so killing the process destroys it: the row still says refunding, no refund is scheduled, and nothing will ever notice — the customer is out the money and the only alarm is the one they raise themselves. In lane B the obligation is a field in saved state with a due time attached, so a completely different isolate picks it up and finishes it. Try the refund scenario and kill it mid-refund; that is the case worth watching.

Where Effect fits in

Lane B's side effects are Effect programs. Taking the payment is an Effect.gen that pulls a Payments service out of the context, calls it, and folds the typed failure into a message the state machine understands:

Effect.gen(function* () {
  const payments = yield* Payments
  const ref = yield* payments.charge({ orderId, amountCents, attempt })
  return { type: "payment_ok", ref, at: Date.now() }
}).pipe(
  Effect.catchTag("PaymentDeclined", (e) =>
    Effect.succeed({ type: "payment_failed", reason: e.reason, at: Date.now() })
  )
)

The services come from a Layer, and the Durable Object builds one ManagedRuntime from that Layer per instance. toInterpret from @demlik/tea/effect lowers the whole dictionary of these programs into the handler table tea already knows how to run. The bridge insists every handler's error channel is discharged inside the effect, which is why catchTag is there: a declined card is not an exception, it is a transition.

What Effect does not own here is the retrying. There is no Schedule, no retry combinator, no long-lived fiber holding the ladder — because all of those live in the process, and the whole point of the demo is the process dying. The decision to retry, the attempt count and the due time are state in a pure reducer; Effect is used for the one thing it is genuinely better at, which is describing a single effectful call and its typed failures.

Where tea fits in

tea is the substrate underneath lane B, and its one rule is that the machine is a plain function: you hand it the current state and something that happened, and it hands back the next state plus a list of things it wants done.

(state, msg) -> [state, cmds]

Because that function is pure, the entire order lives in the state value. How many payment attempts have been made, when the next one is due, which phase we are in, whether a refund is owed and against which payment — all of it is ordinary data:

{ phase: "refunding", attempt: 2, dueAt: 1786952955102, paymentRef: "pay_...", refunded: false }

Compare that to the usual arrangement, where the same facts are implied by where a paused function is sitting — which line of which loop, inside which await. That position cannot be written down, copied, or reloaded. A value can.

The things the saga wants done — take the payment, ask the warehouse, submit the refund — never happen inside the reducer. It only asks for them, by returning them as data, and the handlers carry them out afterwards. So nothing important is sitting in a stack frame: the decisions are in the state, and the work is a list of requests.

After every single transition, tea writes the new state to Durable Object storage before it runs any of the requested effects. That ordering is the whole trick. The saved state is never behind reality, so when a fresh isolate loads it and re-arms the alarm, it resumes exactly where the dead one was — mid ladder, mid reservation, mid refund. Nothing has to be reconstructed or guessed.

The retry policy is one of tea's small built-in batteries, and its state is inspectable rather than hidden. The attempt dots and the countdown on this page are not a separate UI model — they are read straight off the same fields the machine makes its decisions from, which is why they cannot drift out of sync with what the saga is actually doing.

The saga is src/machine.ts — a couple of hundred lines with no Effect, no Durable Object and no clock in sight, which is also why it is testable without any of them. See the @demlik/tea README for the substrate itself.

Five more, all clickable

The same idea applied to workflows that normally take days or weeks — an AI agent pausing for a human approval, a 21-day dunning ladder, an expense report chasing three approvers, an onboarding drip that cancels itself, a device config that keeps retrying until it converges. Each one can be fast-forwarded and killed.

Open the five recipes →