---
title: "Every Cent, Exactly Once: Engineering a Payment Processing System on Cloudflare Workers and Durable Objects"
subtitle: "Single-writer actors, idempotent sagas, and a double-entry ledger: how to make an edge network move money without losing a cent"
discipline: "Computer Science"
articleType: "Methods"
authors: ["Dima Doronin"]
affiliations: ["Independent Researcher"]
correspondence: "doronindm@gmail.com"
keywords: ["Distributed Systems"]
date: "2026-07-20T02:22:09.665Z"
---

# Every Cent, Exactly Once: Engineering a Payment Processing System on Cloudflare Workers and Durable Objects

*Single-writer actors, idempotent sagas, and a double-entry ledger: how to make an edge network move money without losing a cent*

## Abstract

Payment systems are traditionally built on a single relational database whose ACID transactions make correctness easy to state and easy to trust. Serverless edge platforms invert every one of those assumptions: compute is stateless and ephemeral, state is distributed, and no global transaction spans the system. This paper presents a complete architecture for a payment processing system on the Cloudflare developer platform — Workers, Durable Objects, and Queues — and argues that, used carefully, this stack does not merely tolerate financial workloads but suits them unusually well. The design rests on three decisions: every account is a single-threaded Durable Object that serializes its own history; every transfer is itself a Durable Object, named by its idempotency key, that drives a reserve–credit–capture saga; and every movement of money is a balanced double-entry ledger record. We show that four locally checkable invariants — conservation of money, non-negative available balance, persist-before-acknowledge ordering, and terminal convergence of every transfer — survive crashes, retries, duplicate deliveries, and reordering. The result is a payments rail with strong per-account consistency, effectively-once transfer semantics, and a built-in audit trail, achieved without a central database.

## 1. Introduction

Money is boring until you lose some of it. A payment system can be slow or expensive and still survive; it cannot survive being wrong. Every design decision answers to one requirement: after any sequence of requests, retries, crashes, and recoveries, the books must balance.

The traditional answer is a central relational database, with every movement of money wrapped in an ACID transaction whose serializability does the reasoning for you [1]. It works — but it concentrates all writes in one place and sits awkwardly with serverless platforms, where compute is ephemeral and deliberately stateless.

At first glance, Cloudflare's edge looks like the worst possible home for payments: no global database, requests for one account arriving in different cities, at-least-once delivery [2]. This paper argues the opposite: correctness in payments never came from having *one big* database. It came from having, per account, exactly **one writer** with a durable, ordered history. Helland made the general case in 2007: build scalable systems from single-writer entities coordinated by at-least-once messaging and idempotent processing [3]. Durable Objects ship that primitive as a managed service: millions of small, single-threaded, transactionally durable actors, each a global singleton [4].

We contribute (i) an architecture mapping accounts and transfers onto Durable Objects, (ii) a reserve–credit–capture protocol whose idempotency keys are object identities, and (iii) a correctness argument reduced to four invariants, each checkable inside a single object. Nothing here is exotic — just actors, sagas [5], idempotence [6], and double-entry bookkeeping, applied to a substrate that fits them unusually well.

## 2. What the Platform Guarantees

These are documented platform semantics [2, 4], not author inference; where we impose policy on top, we say so.

**Workers** are stateless functions at the edge: authentication, validation, routing — never bookkeeping. **Durable Objects (DOs)** are named, globally unique instances; the platform guarantees at most one live instance per name and routes every request for that name to it [4]. Three properties matter: *single-threaded execution* (one event at a time — no interleavings); *transactional co-located storage* (a private SQLite database with local transactions); and *output gates* (outgoing messages are held until the writes they depend on are durable [4, 7], so an object never tells the world something it has not persisted — write-ahead logging's discipline [1]). **Queues** deliver at least once; duplicates and reordering are possible, loss is not [2]. **Alarms** wake a DO on schedule even after a crash [4].

Absent: global transactions, cross-object locks, exactly-once delivery. The paper is about not needing them.

## 3. Architecture: Small Truths Instead of One Big One

A stateless Worker is the **gateway**. Each account is an **AccountDO** owning its ledger entries, holds, and therefore its balance; as sole writer of its state, per-account operations are serializable by construction [3]. Each transfer is a **TransferDO** — a persistent state machine, named by the client's idempotency key, that walks a saga [5] to completion. Side effects leave through a transactional **outbox** [8] into Queues; consumers are idempotent, and the ledger, never the event stream, is authoritative.

The account schema:

```sql
CREATE TABLE entries (
  id      TEXT PRIMARY KEY,  -- transfer id + leg ("t_abc:debit")
  amount  INTEGER NOT NULL,  -- signed minor units; never floats
  balance INTEGER NOT NULL   -- running balance after this entry
);
CREATE TABLE holds (
  transfer TEXT PRIMARY KEY,
  amount   INTEGER NOT NULL  -- positive; reserved, not yet moved
);
```

The ledger is **double-entry**: a transfer writes a debit of $-a$ on the source and a credit of $+a$ on the destination, each keyed by transfer id and leg, so a replayed leg is a primary-key conflict and cannot double-post. (The key deduplicates legs; the balanced pair is the coordinator's obligation — invariant I1.) A balance is a fold over entries, cached as a running column; availability subtracts holds: $\mathrm{available} = \sum_e e.\mathrm{amount} - \sum_h h.\mathrm{amount}$.

This ledger-first stance is shared with TigerBeetle [9]: it turns "did we lose money?" from a forensic investigation into a periodic reconciliation query.

## 4. The Transaction Protocol

**Idempotency by construction.** Most systems deduplicate idempotency keys against a table — itself delicate under concurrency [6]. DOs allow something cleaner: *the key is the object's name*. Duplicates route to the same TransferDO; a repeat finds the machine already advanced and gets the recorded outcome. Deduplication is a property of addressing; no two coordinators for one transfer can coexist [4].

**Single-account operations** (deposits, withdrawals) are one SQLite transaction inside one actor; the output gate means callers see success only once the write is durable.

**Cross-account transfers** run a saga [5] of idempotent steps — reserve, credit, capture — whose coordinator persists its phase after each step. Every step has two non-crash outcomes: *accepted* (possibly as a replayed no-op) or *rejected* (a business decision; transient faults are retried). The machine is monotonic, with one compensation path:

```
created → reserved → credited → captured     (success)
        ↘ failed                              (reserve rejected)
reserved → releasing → released               (credit rejected)
```

A rejected *reserve* (insufficient funds) aborts cleanly. A rejected *credit* (destination frozen after validation) triggers the compensation: release the hold. The dangerous case is a rejected *capture* after a committed credit — money created on one side, never removed from the other. We close that hole by policy: **capture of an existing hold is always honored** (invariant I0). The hold is the source's own durable record that it authorized the transfer; freezes block new reserves, never captures of granted holds. So `capture` has no rejection branch — it converts the hold atomically, reports success if the debit already exists, or fails transiently and is retried. Rejection exists only where an abort or compensation remains: validate before the point of no return, never after [1].

The coordinator in full:

```ts
async step() {
  switch ((await this.load()).phase) {
    case "created": {
      const r = await this.source.reserve(this.id, this.amount);
      if (!r.ok) return this.finish("failed");    // clean abort
      await this.setPhase("reserved");            // durable first
      return this.step();
    }
    case "reserved": {
      const r = await this.dest.credit(this.id, this.amount);
      await this.setPhase(r.ok ? "credited" : "releasing");
      return this.step();
    }
    case "credited":                    // capture honored by I0
      await this.source.capture(this.id);
      return this.finish("captured");
    case "releasing":                   // compensation; idempotent
      await this.source.release(this.id);
      return this.finish("released");
  }
}
async finish(phase) {
  this.sql.transaction(() => {      // one atomic commit: terminal
    this.sql.setPhase(phase);       //   state + its outbox row [8]
    this.sql.outboxInsert(phase);
  });
  await this.drainOutbox();         // Queue send, at-least-once [2]
}
async alarm() {                     // re-armed after any crash
  await this.step().catch(() => this.retryLater());
}
```

Between `reserved` and `captured` the system is intermediate but *fully accounted*: the hold is visible, reserved funds cannot be double-spent, and an auditor sees where every cent stands. "In flight" is a ledger state, not a gap in it. Because every effect is keyed by transfer id and every acknowledgment gated on durability, at-least-once execution [2] composes with idempotence into *effectively-once* outcomes [3, 6].

## 5. Why This Is Correct

Each invariant is enforced at one point, in one single-threaded object — keeping the argument short enough to trust.

**I1 — Conservation.** Every `captured` transfer's entries satisfy $\sum_e e.\mathrm{amount} = 0$. Mid-saga, every committed credit is matched by a live hold on the source for the same amount — capture converts that hold into the balancing debit in one atomic step, and it is unconditional from `credited` (I0). Global money always equals external deposits minus withdrawals. Only a bug inside one account's few atomic write paths — a few dozen lines, auditable in isolation — could break this.

**I2 — No overdrafts.** Only the source's own `reserve` can encumber funds; it checks $\mathrm{available} \ge a$ and inserts the hold in one transaction on one thread. Racing transfers cannot both pass the check; they cannot run concurrently at all.

**I3 — Persist before promise.** Output gates [4] plus the outbox ensure no acknowledgment or event ever describes unpersisted state.

**I4 — Terminal convergence.** A TransferDO keeps an alarm until terminal; alarms survive crashes [4]; the machine has no cycles; and the steps past the point of no return have no rejection branch. Every transfer reaches `captured`, `failed`, or `released`; none dangles.

One adversarial timeline: the coordinator dies after the credit commits but before recording `credited`. The alarm re-runs the `reserved` step; the duplicate credit hits the primary key and reports success; the saga records `credited` and captures. Every other cut point lands the same way — on an idempotent replay or an already-terminal state. The safety case is a mechanical walk over the saga's step boundaries — the only interleavings that exist.

## 6. Limitations, Honestly

A single object's throughput is bounded by its serial event loop; a hot account that outgrows the platform's per-object guidance [4] shards into roll-up sub-accounts — only where measurement demands it. A transfer costs a few sequential DO round-trips — fine for payments, wrong for an order book. This is a ledger of record, not a card vault: keeping raw card data with a certified tokenizing processor confines most PCI DSS obligations to that processor [10]. And there are no interactive multi-object transactions — a discipline as much as a restriction: every multi-account operation must be an explicit, restartable, compensable protocol [5].

## 7. Conclusion

The instinct that money needs one big database is really the instinct that money needs one writer and one ordered history. That instinct is right — per account. With accounts as actors, transfers as sagas named by their idempotency keys, and a double-entry ledger as the sole truth, an edge network becomes a payments rail on which every cent is accounted for, exactly once, all the time. Money does not need a faster network; it needs a smaller set of places where the truth is allowed to change.

*Note on prior work.* The platform primer (§2) and idempotency framing (§4) condense the author's earlier articles on this platform; the architecture, protocol, and invariants are new.

## References

1. Gray, J., Reuter, A. *Transaction Processing: Concepts and Techniques.* Morgan Kaufmann, 1993.
2. Cloudflare. *Queues: delivery guarantees.* developers.cloudflare.com/queues (accessed July 2026).
3. Helland, P. *Life beyond Distributed Transactions: an Apostate's Opinion.* CIDR, 2007.
4. Cloudflare. *Durable Objects documentation.* developers.cloudflare.com/durable-objects (accessed July 2026).
5. Garcia-Molina, H., Salem, K. *Sagas.* ACM SIGMOD, 1987.
6. Helland, P. *Idempotence Is Not a Medical Condition.* ACM Queue 10(4), 2012.
7. Cloudflare. *Durable Objects: Easy, Fast, Correct — Choose Three.* Cloudflare blog, 2020.
8. Richardson, C. *Microservices Patterns*, ch. 3 (Transactional Outbox). Manning, 2018.
9. TigerBeetle. *Design documentation.* tigerbeetle.com (accessed July 2026).
10. PCI Security Standards Council. *PCI DSS v4.0.* 2022.
