Durable execution with fibers

Last updated Aug 20, 2026 Run work that survives Durable Object eviction. runFiber() registers a task in SQLite, keeps the agent alive during execution, lets you checkpoint intermediate state with stash(), and calls onFiberRecovered() on the next activation if the agent was evicted mid-task.

Use startFiber() when a caller needs to durably accept background work, return quickly, safely dedupe retries, inspect status later, or cancel a running job.

Note

For how fibers fit into the bigger picture of building agents that run for weeks or months, refer to Long-running agents.

Quick start

import { Agent } from "agents";
import type { FiberRecoveryContext } from "agents";

class MyAgent extends Agent {
    async doWork() {
        await this.runFiber("my-task", async (ctx) => {
            const step1 = await expensiveOperation();
            ctx.stash({ step1 });

const step2 = await anotherExpensiveOperation(step1);
            this.setState({ ...this.state, result: step2 });
        });
    }

async onFiberRecovered(ctx: FiberRecoveryContext) {
        if (ctx.name !== "my-task") return;
        const snapshot = ctx.snapshot as { step1: unknown } | null;
        if (snapshot) {
            const step2 = await anotherExpensiveOperation(snapshot.step1);
            this.setState({ ...this.state, result: step2 });
        }
    }
}

Why fibers exist

Durable Objects get evicted for three reasons:

  1. Inactivity timeout — \~70–140 seconds with no incoming requests or open WebSockets
  2. Code updates / runtime restarts — non-deterministic, 1–2x per day
  3. Alarm handler timeout — 15 minutes

When eviction happens mid-work, the upstream HTTP connection (to an LLM provider, an API, a database) is severed permanently. In-memory state — streaming buffers, partial responses, loop counters — is lost. Multi-turn agent loops lose their position entirely.

keepAlive() reduces the chance of eviction. runFiber() makes eviction survivable.

For work that should run independently of the agent with per-step retries and multi-step orchestration, use Workflows instead. Fibers are for work that is part of the agent's own execution. Refer to Long-running agents: Workflows vs agent-internal patterns for a comparison.

keepAlive

Prevents idle eviction by creating a 30-second alarm heartbeat that resets the inactivity timer.

class Agent {
    keepAlive(): Promise<() => void>;
    keepAliveWhile<T>(fn: () => Promise<T>): Promise<T>;
}

keepAliveWhile() is the recommended approach — it runs an async function and automatically cleans up the heartbeat when it completes or throws:

const result = await this.keepAliveWhile(async () => {
    return await slowAPICall();
});

For manual control, keepAlive() returns a disposer. Always call it when done — otherwise the heartbeat continues indefinitely:

const dispose = await this.keepAlive();
try {
    await longWork();
} finally {
    dispose();
}

How it works

While any keepAlive ref is held, an alarm fires every 30 seconds that resets the inactivity timer. When all disposers are called, alarms stop and the DO can go idle naturally.

The heartbeat is invisible to listSchedules() — no schedule rows are created. It does not conflict with your own schedules; the alarm system multiplexes all schedules and the keepAlive heartbeat through a single alarm slot.

Configurable interval

Default: 30 seconds. The inactivity timeout is \~70–140 seconds, so 30 seconds gives comfortable margin. Override via static options:

class MyAgent extends Agent {
    static options = { keepAliveIntervalMs: 2_000 };
}

When to use keepAlive vs runFiber

keepAlive prevents eviction but does nothing about recovery. If the agent is evicted despite the heartbeat (code update, alarm timeout, resource limit), any in-progress work is lost.

runFiber calls keepAlive internally and persists the work in SQLite so it can be recovered. Use keepAlive alone when the work is cheap to redo or does not need checkpointing. Use runFiber when the work is expensive and you need to resume from where you left off.

Scenario Use
Waiting on a slow API call keepAlive()
Streaming an LLM response (via AIChatAgent) Automatic (built in)
Multi-step computation with intermediate results runFiber()
Background research loop that takes 10+ minutes runFiber() with stash()
Webhook job that must be accepted exactly once startFiber()

runFiber

Durable execution with checkpointing and recovery.

class Agent {
    runFiber<T>(name: string, fn: (ctx: FiberContext) => Promise<T>): Promise<T>;
    startFiber(
        name: string,
        fn: (ctx: FiberContext) => Promise<void>,
        options?: StartFiberOptions,
    ): Promise<StartFiberResult>;
    inspectFiber(fiberId: string): Promise<FiberInspection | null>;
    inspectFiberByKey(idempotencyKey: string): Promise<FiberInspection | null>;
    listFibers(options?: ListFibersOptions): Promise<FiberInspection[]>;
    cancelFiber(fiberId: string, reason?: string): Promise<boolean>;
    cancelFiberByKey(idempotencyKey: string, reason?: string): Promise<boolean>;
    deleteFibers(options?: DeleteFibersOptions): Promise<number>;
    resolveFiber(fiberId: string, result: FiberRecoveryResult): Promise<boolean>;
    stash(data: unknown): void;
    onFiberRecovered(
        ctx: FiberRecoveryContext,
    ): Promise<void | FiberRecoveryResult>;
}

type FiberContext = {
    id: string;
    signal: AbortSignal;
    stash(data: unknown): void;
    snapshot: unknown | null;
};

type FiberStatus =
    | "pending"
    | "running"
    | "completed"
    | "aborted"
    | "interrupted"
    | "error";

type FiberRecoveryContext = {
    id: string;
    name: string;
    status?: FiberStatus;
    idempotencyKey?: string;
    metadata?: Record<string, unknown> | null;
    snapshot: unknown | null;
    createdAt: number;
    recoveryReason: "interrupted";
};

Lifecycle

Normal execution

runFiber("work", fn)
  ├─ Persist recovery metadata
  ├─ keepAlive() — heartbeat starts
  ├─ Execute fn(ctx)
  │    ├─ ctx.stash(data) → persist snapshot
  │    ├─ ctx.stash(data) → persist snapshot
  │    └─ return result
  ├─ Delete recovery metadata
  ├─ keepAlive dispose — heartbeat stops
  └─ Return result to caller

Eviction and recovery

[DO evicted — all in-memory state lost]

On next activation:
  ├─ Request/connection → onStart() → check for orphaned fibers  [primary path]
  │  OR
  ├─ Persisted alarm fires → housekeeping check                   [fallback path]

Recovery:
  ├─ Load interrupted fibers from storage
  ├─ For each interrupted fiber:
  │    ├─ Parse snapshot from JSON
  │    ├─ Call onFiberRecovered(ctx)
  │    └─ Delete recovery metadata after successful recovery
  └─ If onFiberRecovered calls runFiber() again → new fiber, normal execution

Both recovery paths call the same hook. The alarm path is critical for background agents that have no incoming client connections — the persisted alarm wakes the agent on its own.

Sub-agents

Fibers also work inside sub-agents. The fiber row and snapshots are stored in the sub-agent's own SQLite database, and onFiberRecovered() runs with the sub-agent as this.

Sub-agents do not have independent alarm slots, so the top-level parent owns the physical heartbeat. When a sub-agent starts a fiber, the parent tracks enough metadata to route recovery checks back into the owning sub-agent, even if the child has no client connection or incoming RPC.

This keeps recovery local to the child while preserving the single physical alarm slot owned by the parent. A recovered continuation can use schedule() from inside the facet; the parent owns the physical alarm and routes the callback back to the child.

Error during execution

fn(ctx) throws Error
  ├─ DELETE row from cf_agents_runs
  ├─ keepAlive dispose
  └─ Error propagates to caller (or logged if fire-and-forget)

No automatic retries. Recovery logic belongs in onFiberRecovered, where you have the snapshot and full context about what went wrong.

Inline vs fire-and-forget

runFiber() supports both patterns:

// Inline — await the result
const result = await this.runFiber("work", async (ctx) => {
    return computeExpensiveThing();
});

// Fire-and-forget — caller does not wait
void this.runFiber("background", async (ctx) => {
    await longRunningProcess();
});

If the DO is evicted during an inline await, the caller is gone. On recovery, onFiberRecovered fires — it cannot return a result to the original caller. This is the inherent limitation of durable execution across process boundaries. For long-running work that is likely to outlive a single DO lifetime, use startFiber() when callers need a retained status record, idempotent acceptance, or cancellation.

startFiber

Use startFiber() when a caller needs to durably accept background work, return quickly, and safely dedupe retries. It stores a retained fiber record before the callback runs, then starts the callback in the background using the same keep-alive and recovery machinery as runFiber().

const receipt = await this.startFiber(
    "reply-to-webhook",
    async (ctx) => {
        ctx.stash({ webhookId, threadId });
        await postReply(threadId);
    },
    {
        idempotencyKey: `webhook:${webhookId}`,
        metadata: { threadId },
    },
);

if (!receipt.accepted) {
    // This webhook was already accepted by an earlier delivery.
}

By default, startFiber() returns after the work is durably accepted. Pass waitForCompletion: true when the caller should remain open until the accepted fiber reaches a terminal status. Duplicate calls with the same idempotency key join an active in-memory execution when possible, then return the retained status with accepted: false.

const result = await this.startFiber("reply-to-webhook", reply, {
    idempotencyKey: `webhook:${webhookId}`,
    waitForCompletion: true,
});

if (result.status === "error") {
    console.error(result.error);
}

startFiber() is a durable acceptance API, not a value-return API. It returns the managed fiber status, but not the callback's result. Inspect status later with inspectFiber() or inspectFiberByKey().

const current = await this.inspectFiberByKey(`webhook:${webhookId}`);

if (current) {
    await this.cancelFiber(current.fiberId, "No longer needed");
}

await this.deleteFibers({
    status: ["completed", "error", "aborted"],
    settledBefore: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
});

By default, deleteFibers() deletes settled completed, error, and aborted rows. It does not delete interrupted rows unless you pass that status explicitly, because interrupted rows often need inspection or manual resolution.

Cancellation is cooperative. cancelFiber() records an aborted terminal state and aborts ctx.signal if the fiber is running in the current isolate. Your callback should check ctx.signal.aborted around expensive work and before visible side effects. Callers using waitForCompletion: true return when the ledger reaches aborted, even if a non-cooperative callback keeps running in the current isolate.

If the Durable Object is evicted mid-fiber, the retained record is marked interrupted and onFiberRecovered() receives the last checkpoint. The original closure cannot be replayed automatically; use ctx.name, ctx.snapshot, and metadata to decide whether to resume, compensate, or leave the record for inspection.

Return a FiberRecoveryResult from onFiberRecovered() to record the policy decision:

async onFiberRecovered(ctx: FiberRecoveryContext) {
    if (ctx.name !== "reply-to-webhook") return;

const snapshot = ctx.snapshot as { webhookId: string; threadId: string };
    await postRecoveryMessage(snapshot.threadId);

return {
        status: "completed",
        snapshot: { ...snapshot, recovered: true },
    };
}

Returning undefined keeps a managed fiber interrupted. Throwing leaves it interrupted and records the recovery error for inspection. Terminal managed fibers such as aborted are not recovered again if a stale run row remains.