Skip to content

SYSTEM Cited by 1 source

Cloudflare Dynamic Workflows

Dynamic Workflows (@cloudflare/dynamic-workflows) is Cloudflare's dispatch library that bridges Workflows (durable execution) with Dynamic Workers (per-request V8-isolate sandboxing), letting a single Worker Loader route every create() call and every subsequent run(event, step) invocation to a different tenant's code (github.com/cloudflare/dynamic-workflows, npm install @cloudflare/dynamic-workflows). MIT-licensed, ~300 lines of TypeScript. Launched 2026-05-01.

What problem it solves

wrangler.jsonc statically binds each Workflow class_name to a single class per deploy:

"workflows": [
  {
    "name": "dynamic-workflow",
    "binding": "WORKFLOW",
    "class_name": "DynamicWorkflow"
  }
]

One binding, one class. That's fine if you own all the code. It breaks the moment you want your customer / tenant / repo / agent to ship their own run(event, step) function — app platforms where the AI writes TypeScript per tenant, multi-tenant SaaS with customer-defined business logic, CI/CD products where each repo has its own pipeline, agents SDKs where each agent writes its own durable plan. Dynamic Workflows lifts the one-class-per-deploy constraint.

Three-layer dispatch topology

┌──────────────────────────────────────┐
│         Workflows engine             │  (platform)
│    (persistence + replay + step      │
│     retries + sleep + waitForEvent)  │
└──────────────────────────────────────┘
               ↑  ↓ run(event, step)
┌──────────────────────────────────────┐
│         Worker Loader                │  (you)
│  — fetch, route, wrapWorkflowBinding │
│  — createDynamicWorkflowEntrypoint   │
└──────────────────────────────────────┘
               ↑  ↓ RPC
┌──────────────────────────────────────┐
│     Tenant code (Dynamic Worker)     │  (your customer)
│  class TenantWorkflow extends        │
│    WorkflowEntrypoint { run(...) }   │
└──────────────────────────────────────┘

The Worker Loader sits in the middle. The engine is above and unchanged. The tenant is below and unchanged — they write "plain, idiomatic Workflows code. They have no idea they're being dispatched." (Source: Dynamic Workflows post.)

Core mechanism: wrap .create() out, wrap WorkflowEntrypoint in

Every load-bearing line of the library is either an outbound wrapper or an inbound wrapper.

Outbound (wrapWorkflowBinding)

The env.WORKFLOWS the tenant sees is a wrapWorkflowBinding({ tenantId }) specialisation, not the real Workflow binding. When the tenant calls env.WORKFLOWS.create(...), the wrapped binding rewrites the payload:

tenant calls:  create({ params: { name: 'Alice' } })
engine sees:   create({ params: {
                 __workerLoaderMetadata: { tenantId: 't-42' },
                 params: { name: 'Alice' }
               }})

…and forwards to the real WORKFLOWS binding. The engine persists the envelope; the metadata rides along with the payload through every sleep, crash, and redeploy. Canonicalised here as concepts/envelope-wrap-and-unwrap-metadata-routing and patterns/metadata-envelope-in-durable-payload.

Inbound (createDynamicWorkflowEntrypoint)

You register exactly one class in wrangler.jsonc. When the engine wakes the workflow — seconds, hours, or days later — it calls .run(event, step) on that registered class. The class unwraps the envelope, hands the metadata to the loadRunner callback you wrote, and forwards the unwrapped event through to whatever runner the callback returns:

export const DynamicWorkflow = createDynamicWorkflowEntrypoint<Env>(
  async ({ env, metadata }) => {
    const stub = loadTenant(env, metadata.tenantId);
    return stub.getEntrypoint('TenantWorkflow');
  }
);

The loadRunner callback is where "everything interesting happens, and it's entirely yours": fetch tenant source from R2, check plan tier, pick a region, attach a tail Worker for per-tenant logging, bundle TypeScript on the fly with @cloudflare/worker-bundler. Canonicalised here as concepts/byo-workflow-per-tenant.

Why the binding must be an exported RPC class

Bindings that cross the Dynamic Worker boundary must be RPC stubs. "A plain { create, get } object can't be structured-cloned, and the raw Workflow binding isn't serializable either." (Source: sources/2026-05-01-cloudflare-introducing-dynamic-workflows-durable-execution-that-follows-the-tenant.)

The wrapped binding is a WorkerEntrypoint subclass (DynamicWorkflowBinding) that the runtime specialises with the tenant's metadata at load time. That's why you must export { DynamicWorkflowBinding } from your Worker Loader — the runtime builds per-tenant stubs by looking the class up in cloudflare:workers exports.

The full minimal Worker Loader

import {
  createDynamicWorkflowEntrypoint,
  DynamicWorkflowBinding,
  wrapWorkflowBinding,
} from '@cloudflare/dynamic-workflows';

// The library looks this class up on cloudflare:workers exports.
export { DynamicWorkflowBinding };

function loadTenant(env, tenantId) {
  return env.LOADER.get(tenantId, async () => ({
    compatibilityDate: '2026-01-01',
    mainModule: 'index.js',
    modules: { 'index.js': await fetchTenantCode(tenantId) },
    // The tenant sees this as a normal Workflow binding.
    env: { WORKFLOWS: wrapWorkflowBinding({ tenantId }) },
  }));
}

export const DynamicWorkflow = createDynamicWorkflowEntrypoint<Env>(
  async ({ env, metadata }) => {
    const stub = loadTenant(env, metadata.tenantId);
    return stub.getEntrypoint('TenantWorkflow');
  }
);

export default {
  fetch(request, env) {
    const tenantId = request.headers.get('x-tenant-id');
    return loadTenant(env, tenantId).getEntrypoint().fetch(request);
  },
};

The tenant's code is unchanged

import { WorkflowEntrypoint } from 'cloudflare:workers';

export class TenantWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    return step.do('greet', async () => `Hello, ${event.payload.name}!`);
  }
}

export default {
  async fetch(request, env) {
    const instance = await env.WORKFLOWS.create({ params: await request.json() });
    return Response.json({ id: await instance.id });
  },
};

Workflow IDs, .status(), .pause(), retries, hibernation, durable steps, step.sleep('24 hours'), step.waitForEvent() — all work unchanged. The tenant has no idea they're being dispatched.

Isolate caching + hibernation economics

  • Dynamic Worker boot: single-digit milliseconds.
  • Dynamic Worker memory: a few megabytes.
  • Cache key: tenant ID (via env.LOADER.get(tenantId, ...)). A workflow that runs many steps over many hours reuses the same dynamic Worker across them.
  • Eviction recovery: when the isolate is evicted, the next step.do() pulls the code again and keeps going — invisible to the workflow.
  • Idle cost: approximately zero. "You can have a million tenants, each with their own distinct workflow code, each spun up lazily on the step boundary where it's needed, and none of them cost anything while idle." (Source: sources/2026-05-01-cloudflare-introducing-dynamic-workflows-durable-execution-that-follows-the-tenant.)

Metadata is a routing hint, not authorization

"The tenant can read it back via instance.status(). Don't put secrets in there." (Source: sources/2026-05-01-cloudflare-introducing-dynamic-workflows-durable-execution-that-follows-the-tenant.)

Tenant isolation / authorization remains the Worker Loader's responsibility — via Dynamic Workers' capability-based bindings (globalOutbound: null + explicit capabilities resource-by- resource).

Lower-level primitive: dispatchWorkflow

If you want to subclass WorkflowEntrypoint yourself — to add logging around run(), wire up per-tenant observability, or thread custom state through — the library exposes the lower-level dispatchWorkflow callable that createDynamicWorkflowEntrypoint is built on:

import { dispatchWorkflow } from '@cloudflare/dynamic-workflows';

export class MyDynamicWorkflow extends WorkflowEntrypoint {
  async run(event, step) {
    return dispatchWorkflow(
      { env: this.env, ctx: this.ctx },
      event,
      step,
      ({ metadata, env }) => loadRunnerForTenant(env, metadata),
    );
  }
}

Positioning: one of three dynamic-binding primitives

Cloudflare frames Dynamic Workflows as the third instance of a generalising pattern:

Layer Static binding Dynamic binding
Compute Workers Dynamic Workers
Storage Durable Objects Durable Object Facets
Durable execution Workflows Dynamic Workflows (this page)
Queues / caches / databases / object stores / AI bindings / MCP servers static pre-announced ("coming")

Canonicalised in patterns/dynamic-binding-over-static-binding.

Canonical showcase: CI/CD as per-repo durable workflow

The customer ships .cloudflare/ci.ts — their own CIPipeline extends WorkflowEntrypoint class. The platform ingests a webhook, figures out which repo it came from, loads that repo's CIPipeline as a Dynamic Worker, and hands execution off to Dynamic Workflows. The platform doesn't know what's in the pipeline — "it doesn't need to. It's running a durable function that happens to live in the customer's repo." (Source: sources/2026-05-01-cloudflare-introducing-dynamic-workflows-durable-execution-that-follows-the-tenant.)

  • Artifacts + ArtifactFS fork() — per-run isolated repo copy in single-digit seconds, no git clone tax.
  • Dynamic Workers — each lightweight step (lint, format, typecheck, bundle) in a millisecond-boot isolate on the same machine as the repo.
  • Dynamic Workflows — holds the run together; steps are retryable + durable; hibernation free while waiting on approvals; state + progress survive deploys / evictions / crashes.
  • Sandboxes — heavy corners (docker build, integration suites that need Postgres, Rust 8-core compiles). Snapshot-restore to R2 means these warm- start in a couple of seconds.

Canonicalised as patterns/ci-pipeline-as-customer-authored-durable-workflow.

Workflows V2 capacity context

Dynamic Workflows runs atop the Workflows V2 rewrite, "redesigned for the agentic era":

  • 50,000 concurrent workflow instances per account.
  • 300 new instances per second per account.

These appear as ambient capacity context for a single-account platform running per-tenant workflows at fleet scale. First wiki disclosure of the V2 capacity envelope.

Role in the agent-autonomy stack

For coding agents — OpenCode, Claude Code, Codex, Pi — Dynamic Workflows is "the piece that lets that plan be a first-class Cloudflare Workflow". An agent literally writes a run(event, step) body; the platform runs it with full durability (step.do() retryable, step.sleep('24 hours') free hibernation, step.waitForEvent() indefinite waits for human approval). Extends Project Think's fiber-based durable execution model with a workflow-as-artifact primitive the model itself can emit.

What's not disclosed

  • No p50/p99 latency numbers for the dispatch hop.
  • No cost disclosure for the tens-of-millions-of-tenants unit-economics claim.
  • No discussion of tenant code update semantics mid-workflow (if a workflow sleeps 24 h and the tenant redeploys, does run() resume on the new code or the old? Cache-by-ID implies eviction brings in the new code, but the determinism implications for replay are not discussed).
  • No comparison against prior-art external dispatchers (Temporal dynamic task queues, Cadence namespaces, serverless-workflow @ref-loaded definitions).
  • No production adopters disclosed at launch — Day 1.

Seen in

Last updated · 438 distilled / 1,268 read