Skip to content

Email delivery with an archive

Compose routing, fanout, consumer RPC, and R2 with independent retries and explicit redrive.

Updated

Build an email service that can accept work quickly, retry sending it, keep an archive, and park failed deliveries for inspection. Sending and archiving are independent obligations, not steps in an atomic transaction.

This walkthrough follows the complete email queue Worker, its Wrangler config, and its Vite wiring.

Architecture and ownership

Durable email fan-out

Shows Worker, Routing client. Highlights the accept a batch flow. Workerfetch handlerRouting clientone shard per pushpush()

1/12 · The Worker pushes through the VIP lane

The demo handler wraps each email as { body }, so fanout will generate message IDs. The lane transform puts the whole batch on the vip shard; the timeout transform bounds the call at five seconds.

Open source ↗
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
import { defineTransform, timeout } from '@durability/transforms';
import {
  DurabilityFanout,
  DurabilityScheduler,
  type DurabilityFanoutMessage,
  type FanoutEnqueueOptions,
  type FanoutInput,
  type StoredMessage,
} from 'durability';
import { DurabilityRouting, type RoutingSession } from 'durability/routing';

type Env = {
  DEAD_LETTERS: R2Bucket;
  EMAIL_API: Fetcher;
  EMAIL_CONSUMER: Service<EmailConsumer>;
};

type Email = {
  to: string;
  subject: string;
  body: string;
  customerId: string;
  region?: DurableObjectLocationHint;
};
type Batch = { messages: FanoutInput<Email>[]; options?: FanoutEnqueueOptions };
type Context = { lane?: string };

export class EmailConsumer extends WorkerEntrypoint<Env> {
  async consume(messages: DurabilityFanoutMessage<Email>[]) {
    for (const message of messages) {
      // The upstream must deduplicate successful side effects across redeliveries.
      // eslint-disable-next-line no-await-in-loop
      const response = await this.env.EMAIL_API.fetch(
        'https://email-api/send',
        {
          method: 'POST',
          headers: { 'Idempotency-Key': message.id },
          body: JSON.stringify(message.body),
        }
      );
      if (response.ok) {
        // eslint-disable-next-line no-await-in-loop
        await message.ack();
      } else if (response.status === 400) {
        // eslint-disable-next-line no-await-in-loop
        await message.deadLetter();
      } else if (response.status === 429) {
        // eslint-disable-next-line no-await-in-loop
        await message.retry(30_000);
      }
      // Unsettled messages retry; exhausting the budget dead-letters this target only.
    }
  }
}

// One bucket, two prefixes: every message is archived, failures are parked.
const archivePrefix = 'email-archive/';
const deadLetterPrefix = 'email-dlq/';
const replayWindowMs = 30 * 24 * 60 * 60 * 1_000;

/** The application owns this DO and composes its capabilities explicitly. */
export class EmailDelivery extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly routing = new DurabilityRouting();
  private readonly fanout = new DurabilityFanout<Email>({
    scheduler: this.scheduler,
    routing: this.routing,
    targets: {
      email: {
        deliver: (messages) => this.env.EMAIL_CONSUMER.consume(messages),
      },
      // A storage destination is just a callback. Keying each object by the
      // delivery identity and refusing to overwrite makes retried writes
      // idempotent, so a redrive deletes exactly what it re-enqueued.
      archive: {
        storage: async (message) => {
          await this.env.DEAD_LETTERS.put(
            `${archivePrefix}${encodeURIComponent(message.id)}`,
            JSON.stringify(message),
            { onlyIf: { etagDoesNotMatch: '*' } }
          );
        },
      },
    },
    dlq: async (message) => {
      await this.env.DEAD_LETTERS.put(
        `${deadLetterPrefix}${encodeURIComponent(message.id)}`,
        JSON.stringify(message),
        { onlyIf: { etagDoesNotMatch: '*' } }
      );
    },
    maxBatchSize: 25,
    attemptTimeoutMs: 30_000,
    retries: { maxAttempts: 5 },
  });

  enqueue(batch: Batch) {
    return this.fanout.enqueue(batch.messages, batch.options);
  }

  override alarm(info?: AlarmInvocationInfo) {
    return this.scheduler.alarm(info);
  }
}

export const routing = DurabilityRouting.client({
  target: EmailDelivery,
  // Vite injects exportName from this application class and Wrangler's config.
  invoke: (stub, batch: Batch) => stub.enqueue(batch),
  sharding: (batch, context: Context | undefined) => ({
    shard:
      context?.lane ??
      `customer-${batch.messages[0]?.body.customerId ?? 'unknown'}`,
    ...(batch.messages[0]?.body.region === undefined
      ? {}
      : { locationHint: batch.messages[0].body.region }),
  }),
});

const lane = defineTransform<
  RoutingSession<Batch, string[], Context>,
  Context
>().caller(
  (name: string) =>
    async ({ next }) =>
      next({ context: { lane: name } })
);

/**
 * Administrative redrive: read the parked entries, route each failed target
 * again, and delete only what was accepted. Reading is application policy, so
 * this walks the bucket directly.
 */
export async function redrive(env: Env, after: number): Promise<number> {
  // Entries older than the replay window are ignored here; an R2 lifecycle
  // rule on the prefix does the physical deletion.
  const cutoff = Math.max(after, Date.now() - replayWindowMs);
  let cursor: string | undefined;
  let accepted = 0;
  do {
    // eslint-disable-next-line no-await-in-loop
    const page = await env.DEAD_LETTERS.list({
      prefix: deadLetterPrefix,
      limit: 100,
      ...(cursor === undefined ? {} : { cursor }),
    });
    for (const object of page.objects) {
      // eslint-disable-next-line no-await-in-loop
      const stored = await env.DEAD_LETTERS.get(object.key);
      if (!stored) {
        continue;
      }
      // This Worker wrote the entry; an application reading foreign data should
      // validate it instead of casting.
      // eslint-disable-next-line no-await-in-loop
      const entry = (await stored.json()) as StoredMessage<Email>;
      if (entry.storedAt < cutoff) {
        continue;
      }
      // The entry ID distinguishes this redrive from the original registration,
      // so it cannot collide with siblings still delivering.
      // eslint-disable-next-line no-await-in-loop
      const result = await routing.push({
        messages: [
          { id: entry.messageId, body: entry.body, deduplicationKey: entry.id },
        ],
        options: { targets: [entry.target] },
      });
      if (!result.success) {
        continue;
      }
      // Never delete on rejection or transport uncertainty. A crash between
      // acceptance and deletion can still duplicate work: this is at-least-once.
      // eslint-disable-next-line no-await-in-loop
      await env.DEAD_LETTERS.delete(object.key);
      accepted += 1;
    }
    cursor = page.truncated ? page.cursor : undefined;
  } while (cursor !== undefined);
  return accepted;
}

export default {
  async fetch(request): Promise<Response> {
    if (request.method !== 'POST') {
      return new Response('POST an email array', { status: 405 });
    }
    // Demo input only: authenticate and validate untrusted payloads in an application.
    const messages = (await request.json<Email[]>()).map((body) => ({ body }));
    const result = await routing
      .with(lane, 'vip')
      .with(timeout, 5_000)
      .push({ messages });
    return Response.json(result, { status: result.success ? 202 : 409 });
  },
} satisfies ExportedHandler<Env>;
  • EmailDelivery owns acceptance, pending delivery state, and one shared scheduler.
  • EmailConsumer is an ordinary WorkerEntrypoint, not a special queue superclass.
  • DurabilityRouting chooses the object and reports advisory load. It does not own the queue’s records.
  • R2 stores archives and dead letters under distinct prefixes in the DEAD_LETTERS bucket binding.
  • The email provider owns the authoritative deduplication of a sent email.

Nothing here requires archive completion before email delivery. If that ordering is a business requirement, this parallel-target design is not sufficient.

Follow one request

  1. The fronting Worker parses a batch and calls routing.push() through caller transforms.
  2. The router picks one shard for the whole push and invokes EmailDelivery.enqueue().
  3. Fanout atomically accepts the messages and snapshots their recipient target IDs. The HTTP response can now return 202.
  4. The scheduler independently drives the email and archive targets. A successful archive does not wait for the provider; a provider failure does not undo an archive.
  5. EmailConsumer.consume() forwards each email to EMAIL_API, using message.id as the provider’s idempotency key.
  6. The consumer awaits ack() on success, requests retry(30_000) on 429, and calls deadLetter() on 400. Other unsettled work retries under the configured policy.
  7. After all target obligations settle, the fanout registration is removed. The R2 archive follows its own retention policy.

The helper wiring is the composition, not a generated queue class:

private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
private readonly routing = new DurabilityRouting();
private readonly fanout = new DurabilityFanout<Email>({
  scheduler: this.scheduler,
  routing: this.routing,
  targets: {
    email: { deliver: (messages) => this.env.EMAIL_CONSUMER.consume(messages) },
    archive: { storage: (message) => this.writeArchive(message) },
  },
  dlq: (message) => this.writeDeadLetter(message),
});

alarm(info?: AlarmInvocationInfo) {
  return this.scheduler.alarm(info);
}

This is a field-level wiring excerpt: Email, writeArchive(), and writeDeadLetter() are application definitions. The complete source inlines the R2 callbacks, sets batches of 25, a 30-second attempt timeout, and five delivery attempts.

Where a simpler design breaks

Imagine accepting an HTTP request, starting the provider request in memory, and returning success without first persisting delivery intent: a stop before the send leaves the caller believing in an email the provider never receives.

The walkthrough’s “Why replying before persisting loses email” scenario steps through this and the lines of the composition that close the gap.

Persisting intent fixes that loss window, but does not make the remote email effect exactly once. A different failure can happen after the provider commits.

The walkthrough’s “Provider effect before acknowledgement” scenario steps through it: the email row stays pending past its claim deadline, the object alarm resumes it, and the same message ID reaches the provider again.

What repairs it: persisted target obligations allow retry; the provider’s idempotency record collapses a duplicate effect. If the provider ignores the key, the design can still send twice. An HTTP timeout is not proof that sending failed.

Archive writes similarly use a stable stored-delivery identity and a conditional R2 write, refusing to overwrite the prior object. A completed archive target is not redelivered merely because its email sibling retries.

Submission identity and shard affinity

The demo HTTP handler creates messages as { body }, so fanout generates IDs. It also applies .with(lane, 'vip'), which overrides customer-based sharding: that route puts the whole batch on the vip shard. It is not a complete production deduplication or tenant-isolation policy.

Before allowing callers to retry submission, assign stable application request IDs:

// requestId and email come from authenticated, validated application input.
await routing.with(timeout, 5_000).push({
  messages: [{ id: requestId, body: email }],
});

Keep retries on the same shard. Fanout deduplication lasts only while its registration remains active and is local to that object; it is not permanent or cross-shard. Keep receiver-side deduplication long enough for the actual replay policy.

Redrive only the failed obligation

The application’s redrive(env, after) function lists the email-dlq/ prefix, applies its 30-day replay window, and re-enqueues only the parked target:

const result = await routing.push({
  messages: [
    { id: entry.messageId, body: entry.body, deduplicationKey: entry.id },
  ],
  options: { targets: [entry.target] },
});
if (result.success) {
  await env.DEAD_LETTERS.delete(object.key);
}

entry and object come from the redrive scan. The DLQ entry ID distinguishes the new registration from the original message’s still-running siblings. Do not replay the archive target merely to retry the email target.

Never delete the parked entry before acceptance or after a transport error. A crash between acceptance and deletion can still cause another redrive: this is at-least-once recovery, not an R2-to-DO transaction.

Failure and recovery contract

Failure Durable state and recovery Application responsibility
Object stops after acceptance Pending target obligations survive; a physical alarm resumes them Wire the shared scheduler’s alarm()
Email succeeds but acknowledgement is lost Email target retries with the same message ID Provider deduplicates the effect
Archive succeeds while email fails Archive stays settled; email progresses independently Do not mistake archiving for delivery
One target exhausts its attempts Only that target is dead-lettered; siblings continue Inspect and authorize redrive
R2 dead-letter write fails Durable terminal intent remains; DLQ writing retries Monitor storage availability
Redrive accepts, then stops before deleting The same parked entry may be replayed again Keep external effects idempotent
Original target ID is removed Its accepted work remains pending with errors/backoff Keep target IDs stable or plan a migration

Provisioning and production changes

  • Provision the DEAD_LETTERS bucket, the EMAIL_API service, and the EMAIL_CONSUMER service entrypoint binding.
  • Declare EmailDelivery as SQLite-backed. Use the Vite doTransforms plugin to resolve the routing target, or wire its exportName/namespace explicitly when running Wrangler alone.
  • Authenticate callers, validate every email, and choose tenant-aware routing; the source HTTP handler is intentionally a sketch.
  • Protect administrative redrive. The exported redrive() function is not exposed as an HTTP route by the demo.
  • Configure an R2 lifecycle rule for the DLQ prefix. Filtering a scan by age does not physically expire objects. Archive retention is independent.

Primitives: fanout, routing, scheduler. Contrast this with a replayable event stream when consumers need history, not per-message delivery attempts.

Type to search…

↑↓ navigate↵ selectEsc close