Skip to content

Durability

Persist, retry, and retrieve the result of individually identified operations.

Import Durability from durability. Use it for individually identified jobs whose results must survive retries and restarts. See recurring billing for composition with named alarms.

How it uses your Durable Object

Durability adds no storage or infrastructure of its own. It uses the Durable Object it is constructed in:

  • ctx.storage.sql holds its tables, so operations are as durable as the object and belong to it alone. Another object, even of the same class, has its own rows.
  • The object’s one alarm (ctx.storage.setAlarm) brings the object back after eviction. Every write that adds or reschedules pending work sets the alarm in the same storage transaction, and your class’s alarm() must delegate to durability.alarm().
  • The live instance holds everything else: concurrency permits, the running attempts, and a timer for work due before the alarm. It is rebuilt with the helper whenever the runtime creates a new instance of the object.

How Durability uses your Durable Object

Shows ImageJobs, Durability. Focuses on Durability.One Durable ObjectImageJobsyour Durable ObjectDurabilityin memoryresizeImage()

1/9 · The helper belongs to the object instance

Durability is constructed with this.ctx, the object’s DurableObjectState. It reads and writes only this object’s storage; another object has its own tables and its own alarm.

Open source ↗
/**
 * `Durability` example: at-least-once operations with a stable idempotency key.
 *
 * A resize request is registered durably and survives eviction, restarts, and
 * transient API failures. The handler runs at least once; passing the stable
 * call id as the resize idempotency key lets the image service collapse
 * duplicate attempts into one effect. The persisted result is read back with
 * `getResult`.
 */
import { DurableObject } from 'cloudflare:workers';
import { Durability, NonRetryableError, type DurableCall } from 'durability';

type Env = {
  IMAGE_JOBS: DurableObjectNamespace<ImageJobs>;
  /** Upstream image service; resize calls must be idempotent. */
  IMAGE_API: Fetcher;
};

type ResizeInput = { imageId: string; width: number };
type ResizeOutput = { url: string };

export class ImageJobs extends DurableObject<Env> {
  private readonly durability = new Durability({
    context: this.ctx,
    handlers: {
      resizeImage: async ({
        id,
        payload,
        signal,
      }: DurableCall<ResizeInput>): Promise<ResizeOutput> => {
        // Permanent input problems should not burn retry attempts.
        if (!Number.isInteger(payload.width) || payload.width <= 0) {
          throw new NonRetryableError(`Invalid width ${payload.width}`);
        }

        // The idempotency key lets the upstream service deduplicate a retry
        // whose previous attempt succeeded after we stopped waiting.
        const response = await this.env.IMAGE_API.fetch(
          'https://image-api/resize',
          {
            method: 'POST',
            headers: { 'Idempotency-Key': id },
            body: JSON.stringify(payload),
            signal,
          }
        );
        if (!response.ok) {
          throw new Error(`Resize failed with ${response.status}`);
        }
        return response.json();
      },
    },
    attemptTimeoutMs: 60_000,
    retries: { maxAttempts: 5 },
  });

  /** Resolves once the job is durably registered, not when it completes. */
  resize(imageId: string, width: number) {
    return this.durability.resizeImage({
      id: `resize:${imageId}:${width}`,
      payload: { imageId, width },
    });
  }

  result(imageId: string, width: number) {
    return this.durability.resizeImage.getResult(`resize:${imageId}:${width}`);
  }

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

export default {
  async fetch(request, env): Promise<Response> {
    const url = new URL(request.url);
    const [, imageId, action] = url.pathname.split('/');
    if (!imageId || !action) {
      return new Response('Expected /:imageId/resize or /:imageId/result', {
        status: 404,
      });
    }
    const width = Number(url.searchParams.get('width') ?? 512);
    const jobs = env.IMAGE_JOBS.getByName(imageId);

    if (request.method === 'POST' && action === 'resize') {
      await jobs.resize(imageId, width);
      return new Response('registered', { status: 202 });
    }
    if (action === 'result') {
      return Response.json(await jobs.result(imageId, width));
    }
    return new Response('Not found', { status: 404 });
  },
} satisfies ExportedHandler<Env>;

Behind the scenes

Durable state: durability_calls holds one row per operation ID: handler name, serialized payload, generation, status, attempt count, retry time, error, and result. Shared durability_memos holds intermediate memoized results. Pending-time and creation-time indexes support scheduling and purge.

  1. Registration serializes the payload and inserts a pending row in a storage transaction. An existing ID must match both handler and canonical payload. The transaction also reconciles the physical alarm.
  2. A new operation starts eagerly only if the shared scheduler has an immediately available permit. Otherwise the persisted due row waits for a timer/alarm pass.
  3. Before calling the handler, the engine durably claims and increments its attempt. An in-memory active lock prevents overlapping same-instance execution.
  4. Successful settlement conditionally stores the JSON result. Failure either records the next retry time or a terminal failed state. Generation/attempt checks prevent a late old execution from overwriting a purged-and-reused ID.

After eviction: the row survives, but locks and running promises do not. A pending handler can run again with the same ID. A remote effect may already have committed; receiver-side idempotency is still necessary. Abort is cooperative: a handler that ignores its signal holds the live permit until it actually settles.

Lifetime: completed and failed records stay indefinitely until destructive purgeBefore(). Purging also removes deduplication protection and memos.

Implementation: durability.ts (DurabilityCore), storage.ts (attempt claims and conditional updates).

Durable operations

import { DurableObject } from 'cloudflare:workers';
import { Durability, type DurableCall } from 'durability';

type ResizeInput = { imageId: string };

export class ImageJobs extends DurableObject<Env> {
  private readonly durability = new Durability({
    context: this.ctx,
    handlers: {
      resizeImage: async ({ id, payload }: DurableCall<ResizeInput>) =>
        this.env.IMAGES.resize(payload.imageId, { idempotencyKey: id }),
    },
    retries: { maxAttempts: 3 },
    methods: { resizeImage: { attemptTimeoutMs: 60_000 } },
  });

  resize(imageId: string) {
    return this.durability.resizeImage({
      id: `resize:${imageId}`,
      payload: { imageId },
    });
  }

  result(imageId: string) {
    return this.durability.resizeImage.getResult(`resize:${imageId}`);
  }

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

Every handler becomes a method on the instance. Handler names that collide with instance members (alarm, purgeBefore) are rejected at construction.

Reading results

const result = await this.durability.resizeImage.getResult(`resize:${imageId}`);

switch (result.status) {
  case 'not_found':
    break;
  case 'pending':
    console.log(result.attempt, result.nextAttemptAt, result.lastError);
    break;
  case 'failed':
    console.error(result.error.name, result.error.message);
    break;
  case 'completed':
    console.log(result.result);
    break;
}

The completed result type is inferred from the handler. Looking up a key that belongs to another operation throws DuplicateDurableCallError. Payloads and results must be JSON-serializable; as with JSON.stringify, undefined properties are dropped and undefined array elements become null.

An ID stays bound to its operation and payload. Registering it again with the same operation and the same data (object key order does not matter; array order and values do) is a no-op, which is what makes retries safe. Registering it with another operation throws DuplicateDurableCallError, and with a different payload DuplicateDurablePayloadError, instead of silently dropping the new call.

Type to search…

↑↓ navigate↵ selectEsc close