Skip to content

DurabilityRouting

Advisory load observation and application-owned shard selection.

Import DurabilityRouting from durability/routing. It combines an object-side load observer with an application-wired routing client; it is not a durable queue or global shard registry.

How it uses your Durable Object

Routing has two halves, and neither adds storage or infrastructure of its own:

  • The calling Worker holds the routing client. Recent per-shard load and the adaptive shard width live in that isolate’s memory and are never persisted or shared with other isolates.
  • Your class is the shard. The client resolves your exported class, calls getByName(shard), and runs only the RPC your invoke callback makes. Each shard is an ordinary object with its own SQLite storage and its own deduplication records.
  • Inside the object, DurabilityRouting owns no table and no alarm. It counts in-flight work and keeps 60 one-second timing buckets in memory, which eviction discards; the backlog it reports is counted from the attached capability’s rows.

How DurabilityRouting uses your Durable Objects

Shows Worker, Routing client. Focuses on Routing client.Workerfetch handlerRouting clientisolate memorypush(batch)

1/9 · The client lives in the calling Worker

DurabilityRouting.client() runs at module scope in the Worker, not in a Durable Object. It binds a target class, an invoke callback that names the RPC, and a sharding function.

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>;

Behind the scenes

Durable state: none. RoutingLoad maintains rolling in-memory time buckets and instantaneous counters. Backlog values come from the attached capability’s durable state; telemetry itself disappears on eviction.

The client selects exactly one shard per push and invokes your invoke(stub, input) callback once. Custom sharding can inspect caller context and recent observations; the default compares two candidates within an adaptive in-memory shard width. Observations expire and are bounded to 1,024 entries, not coordinated globally.

On failure: no durable retry intent is created, and routing does not silently switch destinations. A timed-out RPC may already have committed work. Retrying on another shard may bypass that object’s deduplication record; stable application routing and receiver idempotency remain your responsibility. Context stays caller-local and is not automatically sent to consumers.

See email delivery and partitioned event streams for two different uses of the same routing abstraction.

Implementation: routing.ts, routing-client.ts, and load.ts.

Routing and fanout

A queue is a composition, not another exported abstraction. An application owns its DO class and attaches the capabilities it needs:

import { DurableObject } from 'cloudflare:workers';
import {
  DurabilityFanout,
  DurabilityScheduler,
  type FanoutInput,
  type StoredMessage,
} from 'durability';
import { DurabilityRouting } from 'durability/routing';

export class Mailbox extends DurableObject<Env> {
  private readonly scheduler = new DurabilityScheduler({ context: this.ctx });
  private readonly routing = new DurabilityRouting();
  private readonly fanout = new DurabilityFanout<Job>({
    scheduler: this.scheduler,
    routing: this.routing,
    targets: {
      consumer: { deliver: (messages) => this.env.CONSUMER.consume(messages) },
      archive: { storage: (message) => this.store('archive/', message) },
    },
    dlq: (message) => this.store('dead-letters/', message),
  });

  private async store(prefix: string, message: StoredMessage<Job>) {
    await this.env.ARCHIVE.put(
      `${prefix}${encodeURIComponent(message.id)}`,
      JSON.stringify(message),
      { onlyIf: { etagDoesNotMatch: '*' } }
    );
  }

  enqueue(messages: FanoutInput<Job>[]) {
    return this.fanout.enqueue(messages);
  }

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

const routing = DurabilityRouting.client({
  target: Mailbox,
  invoke: (stub, messages: FanoutInput<Job>[]) => stub.enqueue(messages),
  sharding: (messages, context: { tenant?: string } | undefined) => ({
    shard: context?.tenant ?? messages[0]?.body.customerId ?? 'default',
    locationHint: 'weur',
  }),
});

const result = await routing.push([{ id: 'job:42', body: job }], {
  tenant: 'acme',
});

The application chooses the RPC method via invoke; routing does not know about messages or fanout. A single push selects one shard for the entire input. Static fanout target IDs identify independent delivery obligations; each accepted message snapshots its recipients. Targets added later do not receive old work, and a removed target’s work remains durable with an error and backoff until that ID is restored. Target callbacks/storage references are fixed at capability construction.

Load and adaptive routing

A snapshot contains observedAt, windowMs: 60000, and:

  • inbound: instantaneous inFlight, rolling completed, and averageProcessingMs for enqueue calls through acceptance/rejection.
  • outbound: the same metrics for target delivery attempts (one batch to one target is one attempt), plus exact durable pendingDeliveries counting outstanding message-target pairs.

Counts and averages share a rolling window with one-second buckets. No samples means null, not zero; telemetry resets on eviction while backlog survives. A DO cannot measure requests waiting in the platform before its handler starts. Snapshots are advisory, not capacity reservations or global metrics.

sharding(input, context, observations) can inspect recent per-shard snapshots. Caller context can contain arbitrary local values; it is not persisted or sent to consumers. A location hint influences initial shard placement, not existing DO placement.

Without custom sharding, routing starts with one shard, samples two candidate shard keys, and compares backlog followed by in-flight processing pressure. It widens under backlog pressure (softBacklogLimit, default 1000) and narrows slowly when observations are shallow. Observations expire and are bounded per isolate; this is a best-effort adaptive router, not a globally coordinated autoscaler. Stable-key sharding gives affinity; random routing does not guarantee ordering between separate pushes.

Transforms and build-time wiring

Routing uses the actual @durability/transforms runtime. For example, routing.with(timeout, 5000).with(tenant, 'acme').push(input) composes existing caller transforms. tenant is created with defineTransform<RoutingSession<Input, Value, Context>, Context>().caller(...); context contributions shallow-merge, with transform fields overriding matching positional fields. An RPC timeout is not cancellation. Retrying a push may select a new shard, so choose stable keys when retry affinity matters.

doTransforms from @durability/transforms/vite maps DurabilityRouting.client({ target: Mailbox, ... }) to its same-module exported class name and validates it against Wrangler. It injects exportName, not a generated DO or a magic application method. Without the plugin, set exportName: 'Mailbox' explicitly; namespace: () => env.MAILBOXES is an escape hatch for explicit or remote bindings.

Loopback namespaces need the application DO declared in Wrangler migrations (or the platform’s supported class configuration), but no redundant DO binding block is needed. Consumer transport is explicitly wired by the application, such as a service binding or loopback entrypoint. See the typechecked email queue composition.

Type to search…

↑↓ navigate↵ selectEsc close