---
title: "Cloudflare Workers and Durable Objects: Small DOs and DON'Ts That Prevent Big Mistakes"
subtitle: "A code-first DO/DON'T guide to state, retries, background work, and external effects at the edge"
discipline: "Computer Science"
articleType: "Case Study"
authors: ["Dima Doronin"]
keywords: ["Distributed Systems", "serverless computing", "edge computing", "actor model", "programming models"]
date: "2026-07-19T02:55:15.030Z"
---

# Cloudflare Workers and Durable Objects: Small DOs and DON'Ts That Prevent Big Mistakes

*A code-first DO/DON'T guide to state, retries, background work, and external effects at the edge*

## Abstract

Cloudflare Workers and Durable Objects reduce operational overhead, but ordinary server-side habits can still create duplicate writes, lost work, and conflicting state. This code-first guide presents concrete DOs and DON'Ts for the decisions that matter most: keeping request code ephemeral, using one Durable Object as the owner of a narrow invariant, accepting retries through idempotency records, and handing essential asynchronous work to durable storage. Each pattern includes a deliberately unsafe version, a safer alternative, and a review question. The guidance is based on Cloudflare documentation and distributed-systems literature; it is a practical design guide, not a benchmark.

Cloudflare Workers are excellent at short request work \[1\]. Durable Objects give a particular entity—a cart, room, seat, account, or document—a named home for state \[2\]. The hard part is not calling their APIs. It is deciding what must remain true when requests overlap, a client retries, or an external service answers ambiguously.

This guide is organized around those decisions. Most sections pair a **DON'T** with a **DO**, code, and a question to ask before deploying; the final section is a test checklist. The examples are small TypeScript sketches: adapt auth, validation, schemas, and error handling to the application.

# 1. Map the feature before writing code

Start by naming four boundaries:

| Boundary | Question | Typical home |
| --- | --- | --- |
| Ephemeral work | Can this be recomputed after an isolate disappears? | Worker request handler |
| Entity state | Which one thing must make this decision? | One Durable Object or durable database record |
| Acceptance | What must be true before returning success? | A completed durable write or durable job record |
| External effect | What if the provider acted but the response was lost? | Idempotency key, retry policy, and reconciliation |

**DO** write these answers down for each mutation endpoint. **DON'T** treat a reused Worker isolate as a database, lock manager, or job queue. Reuse can improve startup performance, but module memory is not a durable or globally shared coordination mechanism \[1\].

# DON'T: store application truth in a module variable

```text
// Unsafe: another isolate may not see this, and it vanishes on replacement.
let availableSeats = 1;

export default {
  async fetch() {
    if (availableSeats <= 0) return new Response("sold out", { status: 409 });
    availableSeats -= 1;
    return new Response("reserved");
  },
};
```

## DO: route the invariant to the entity that owns it

```text
export interface Env {
  SEAT: DurableObjectNamespace;
}

export default {
  async fetch(request: Request, env: Env) {
    const seatId = new URL(request.url).searchParams.get("seat")!;
    const id = env.SEAT.idFromName(seatId);
    return env.SEAT.get(id).fetch(request);
  },
};
```

**Review question:** if two requests arrive on different isolates, where is the one durable fact that resolves their conflict?

# 2. Keep Worker handlers small; keep state transitions explicit

A request handler should authenticate, validate, identify the owner, and respond. It may orchestrate work, but must not hide business state in memory or build an accidental long-running transaction.

## DON'T: read and write an invariant through unrelated requests

The unsafe shape is a `SELECT` of `remaining` in one statement followed by a separate `UPDATE`: two concurrent callers can both observe "available" and both decrement, overselling the seat.

## DO: make one named object perform one state transition

```text
export class Seat implements DurableObject {
  constructor(private state: DurableObjectState) {}

  async fetch(request: Request): Promise<Response> {
    if (request.method !== "POST") return new Response("method not allowed", { status: 405 });

    const result = await this.state.storage.transaction(async (txn) => {
      const remaining = (await txn.get<number>("remaining")) ?? 1;
      if (remaining < 1) return { ok: false as const };
      await txn.put("remaining", remaining - 1);
      return { ok: true as const, reservationId: crypto.randomUUID() };
    });

    return Response.json(result, { status: result.ok ? 201 : 409 });
  }
}
```

**DO** scope the object identity to the invariant: a seat, cart, room, or account; the storage `transaction` API used here is documented in \[9\]. **DON'T** send all unrelated state through one “global” object, and don't assume calls to different objects form one atomic transaction \[2,3\].

**Review question:** is the object name the smallest boundary that must agree immediately?

# 3. Make a repeat request return the original outcome

Clients retry after timeouts. Webhook providers retry after non-success responses. A successful response can be lost in transit. Assume a mutation may be delivered more than once.

## DON'T: make the same `POST` create a new effect every time

```text
// Unsafe: retrying this request creates another reservation.
await state.storage.put(crypto.randomUUID(), { userId, seatId });
return new Response("created", { status: 201 });
```

## DO: persist an idempotency record with bounded retention

```text
type Reservation = { reservationId: string; userId: string };
type IdemRecord = { fingerprint: string; result: Reservation };

async function reserve(
  storage: DurableObjectStorage,
  key: string,
  userId: string,
  seatId: string,
): Promise<Reservation> {
  // The same key with different input is a caller bug, not a retry.
  const fingerprint = JSON.stringify({ userId, seatId });

  return storage.transaction(async (txn) => {
    const recordKey = `idem:${key}`;
    const prior = await txn.get<IdemRecord>(recordKey);
    if (prior) {
      if (prior.fingerprint !== fingerprint) {
        throw new Error("idempotency key reused with different input");
      }
      return prior.result;
    }

    const remaining = (await txn.get<number>("remaining")) ?? 1;
    if (remaining < 1) throw new Error("sold out");

    const result = { reservationId: crypto.randomUUID(), userId };
    await txn.put("remaining", remaining - 1);
    await txn.put(recordKey, { fingerprint, result });
    return result;
  });
}
```

The key must identify one intended operation, not merely one user. The stored fingerprint makes a replay return the original result, while the same key reused with different input is rejected. Only successful outcomes are recorded here: a request rejected as sold out may legitimately succeed on a later retry; persist terminal failures too when they must be stable. In production, expire these records on a documented schedule (for example, with a Durable Object alarm) that outlasts normal retry behavior.

**DO** design for at-least-once delivery plus idempotent effects. **DON'T** claim exactly-once delivery without defining the storage, retention, and external-system boundaries that make that claim meaningful \[4,5\].

**Review question:** after a timeout, can a replay safely produce the same response and no second business effect?

# 4. Return success only after a durable fact exists

A `200` or `201` should mean something precise. Either the essential change is durable now, or a durable system has accepted responsibility for completing it later.

## DON'T: put required work only in `waitUntil`

```text
// Unsafe for a required email or billing action.
ctx.waitUntil(sendReceipt(order));
return Response.json({ accepted: true });
```

`waitUntil` extends execution for work after the response, but it is not a durable queue \[6\]. It fits best-effort work—analytics, cache warming, telemetry—that can be lost without breaking a user-visible promise.

## DO: persist an outbox record before acknowledging

```text
await env.DB.batch([
  env.DB.prepare(
    "INSERT INTO orders (id, user_id, status) VALUES (?, ?, 'accepted')"
  ).bind(orderId, userId),
  env.DB.prepare(
    "INSERT INTO outbox (id, kind, payload, status) VALUES (?, 'receipt', ?, 'pending')"
  ).bind(crypto.randomUUID(), JSON.stringify({ orderId, userId })),
]);

return Response.json({ orderId, status: "accepted" }, { status: 202 });
```

This pattern depends on the order and the outbox record becoming durable together; D1's documentation describes `batch` as a SQL transaction that rolls back if any statement fails \[8\]. On a store without atomic multi-statement writes, write the outbox record first and have consumers ignore it until its order row exists.

A queue consumer, scheduled worker, or other durable processor can later claim the pending outbox record, deliver the effect, and record the result \[7\]. Use explicit retry limits and an inspection or dead-letter path.

**DO** use background completion for optional work. **DON'T** make “we started an asynchronous promise” the only evidence that the system owns a critical task.

**Review question:** if the Worker stops immediately after the response, can an operator still locate and finish the promised work?

# 5. Treat external calls as a separate failure domain

A Durable Object can coordinate its own state, not turn an HTTP call to another provider into a shared transaction. The remote side may succeed while the local process sees a timeout, fail before receiving the request, or end up unknown.

## DON'T: hold business state hostage to an untracked remote call

```text
// Unsafe: a timeout leaves the final remote outcome unclear.
await state.storage.put("payment", { status: "charging" });
const response = await fetch("https://payments.example/charge", {
  method: "POST",
  body: JSON.stringify({ amount: 5000 }),
});
await state.storage.put("payment", { status: response.ok ? "paid" : "failed" });
```

## DO: store intent, send with a stable provider key, reconcile uncertainty

```text
// Step 1 — request path: record durable intent before any external call.
const operation = {
  id: crypto.randomUUID(),
  kind: "charge",
  status: "pending",
  providerKey: `charge:${orderId}`,
};
await state.storage.put(`operation:${operation.id}`, operation);
```

```text
// Step 2 — a retryable processor claims the pending operation and sends it.
// Assumes the provider deduplicates requests by this key (verify its API
// documents idempotency-key semantics).
const response = await fetch("https://payments.example/charge", {
  method: "POST",
  headers: { "Idempotency-Key": operation.providerKey },
  body: JSON.stringify({ amount: 5000, orderId }),
});
```

On a definitive result, record completion. On a timeout or ambiguous failure, query the provider by the stable key before retrying. This is an outbox-style protocol: the durable intent makes recovery possible even when no single process saw the whole exchange \[5\].

**DO** define timeout, retry, and reconciliation behavior per provider. **DON'T** infer “the other side did nothing” from a local network error.

**Review question:** if execution stops immediately before or after an external request, which stored record drives recovery?

# 6. Test the failure paths that local demos miss

A stateful endpoint's test matrix should include at least:

1. two concurrent requests for the same entity;
2. the same idempotency key sent twice;
3. a retry after the client loses the response;
4. object restart with previously persisted state;
5. a background consumer failure and retry;
6. an external timeout after the provider may have acted.

Add structured logs for entity ID, operation ID, idempotency key, retry count, and terminal status—never sensitive bodies or credentials—and alert on growing pending work, repeated failures, and operations that exceed their completion window.

## References

1. Cloudflare Docs. "How Workers works." https://developers.cloudflare.com/workers/reference/how-workers-works/
2. Cloudflare Docs. "What are Durable Objects?" https://developers.cloudflare.com/durable-objects/what-are-durable-objects/
3. P. Helland. "Life beyond Distributed Transactions: an Apostate's Opinion." *CIDR*, 2007. https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf
4. M. Kleppmann. *Designing Data-Intensive Applications.* O'Reilly Media, 2017.
5. C. Richardson. "Pattern: Transactional outbox." https://microservices.io/patterns/data/transactional-outbox.html
6. Cloudflare Docs. "Context (ctx) — waitUntil." https://developers.cloudflare.com/workers/runtime-apis/context/
7. Cloudflare Docs. "Cloudflare Queues." https://developers.cloudflare.com/queues/
8. Cloudflare Docs. "D1 Workers Binding API — batch statements." https://developers.cloudflare.com/d1/worker-api/d1-database/
9. Cloudflare Docs. "Durable Objects Storage API." https://developers.cloudflare.com/durable-objects/api/storage-api/
