Durable execution without a static deployment

When Workers launched eight years ago, it targeted individual developers directly. Since then, the platform has expanded to support multi-tenant applications where platforms themselves rely on Workers to run code that their own customers have written or configured. The workloads vary widely: AI-generated implementations, per-customer business logic running as TypeScript, agents that author and execute their own tools, and CI/CD products where each repository defines its own pipeline.

Over the past month, Cloudflare has been building out a "dynamic" counterpart to its core primitives. The Dynamic Workers open beta allows a platform to hand the Workers runtime code at runtime and receive an isolated Worker back in single-digit milliseconds. Durable Object Facets applied the same idea to storage, giving each dynamically-loaded app its own on-demand SQLite database. Artifacts extended it to versioned, Git-native source control, creatable by the tens of millions. Until now, durable execution was missing that treatment.

Dynamic Workflows closes that gap. It is a small library (@cloudflare/dynamic-workflows, roughly 300 lines of TypeScript) that lets a single Worker — the Worker Loader — route workflow creation to different tenants' code, and have the Workflows engine dispatch run(event, step) back into that same code when execution actually begins, whether that is seconds, hours, or days later.

The static assumption in Workflows

Cloudflare Workflows is the durable execution engine on Workers. It turns a run(event, step) function into a program where each step survives failures, sleeps for long periods, waits for external events, and resumes exactly where it left off after isolate recycling. It handles onboarding flows, transcoding pipelines, multi-stage billing, long-running agent loops, and — since Workflows V2 — scales to 50,000 concurrent instances and 300 new instances per second per account.

The existing model, however, has one baked-in assumption: workflow code must be part of the deployment. A wrangler.jsonc binds the engine's WORKFLOWS entrypoint to a single class, per deploy. That serves traditional applications well, but it fails the moment you want customers to ship their own workflow logic — in an app platform where AI writes TypeScript per tenant, a CI/CD product with per-repo pipelines, or an agent SDK where each agent writes its own durable plan. There the workflow differs per tenant, agent, or request; no single class suffices.

Envelope-and-unwrap in the middle

Dynamic Workflows maintains a three-layer architecture: the Workflows engine at the top, the Worker Loader in the middle, and the tenant's code — a Dynamic Worker — at the bottom.

BLOG-3243 1

A Worker Loader routes a request to the correct tenant's dynamic code at runtime, then hands off execution between the layers over time: the request enters, bounces to the engine, gets persisted, and later bounces back down. The flow works in six steps:

① → ② Entering the tenant's code. The Worker Loader receives an HTTP request, identifies the tenant, loads that tenant's code, and forwards the request to its default.fetch. The env the tenant receives contains WORKFLOWS: wrapWorkflowBinding({ tenantId }) — which, to the tenant, behaves exactly like a normal Workflow binding.

③ Up to the Worker Loader. When tenant code calls env.WORKFLOWS.create({ params }), it triggers an RPC into the Worker Loader. The wrapped binding is a subclass of WorkerEntrypoint called DynamicWorkflowBinding, which the runtime specializes with tenant metadata at load time. That requires export { DynamicWorkflowBinding } from the Worker Loader so the runtime can build per-tenant stubs by looking the class up in cloudflare:workers exports. Bindings crossing the Dynamic Worker boundary must be RPC stubs, because a plain object cannot be structured-cloned and the raw Workflow binding is not serializable either. Inside the Worker Loader, the wrapper rewrites the payload with an envelope containing the tenant's metadata:

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

④ Up to the engine. The Worker Loader calls .create() on the real WORKFLOWS binding, with the envelope as params. The engine then persists event.payload — now including the envelope — and schedules the run. Every time the engine later wakes the workflow (after a 24-hour sleep, a crash, or a deploy), the metadata rides along in the payload for routing. Since the tenant can read this metadata back via instance.status(), it is a routing hint rather than an authorization mechanism; secrets should not be placed there.

⑤ → ⑥ The engine comes back down. When the engine is ready to run a step, it calls .run(event, step) on the class registered in wrangler.jsonc — the one produced by createDynamicWorkflowEntrypoint. That class unwraps the envelope, passes the metadata to your loadRunner callback, and forwards the unwrapped event to the runner the callback returns.

The callback is where loaders implement their own logic: fetching the tenant's latest source from R2, choosing a region by plan tier, attaching a tail Worker for logging, or bundling TypeScript on the fly with @cloudflare/worker-bundler. In the common case, the callback delegates right back to the Worker Loader:

const stub = env.LOADER.get(tenantId, () => loadTenantCode(tenantId));
return stub.getEntrypoint('TenantWorkflow');

The Worker Loader caches by ID, so a workflow running many steps over many hours reuses the same dynamic Worker. After isolate eviction, the next step.do() pulls the code again and execution continues without the tenant knowing. Boot time takes single-digit milliseconds on a few megabytes of memory, which keeps dispatch overhead effectively free. A platform can serve a million tenants, each with distinct workflow code, spun up lazily at step boundaries, with no idle cost.

Lower-level control

For deeper customization — logging around run(), per-tenant observability, or threading custom state — the library also exposes dispatchWorkflow, the lower-level primitive underneath createDynamicWorkflowEntrypoint:

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),
    );
  }
}

All other behavior — IDs, pause/resume, sendEvent, retries — flows through to the real Workflows engine untouched.

One primitive, many bindings

The library's essential logic is wrappers: one around .create() on the outbound path, one around WorkflowEntrypoint on the inbound path. Dynamic Workers underneath handle the real work — spinning up tenant code, sandboxing, RPC routing, isolate caching, and hibernation between steps.

Dynamic Workflows thus extends the pattern established by Dynamic Workers and Durable Object Facets to the WorkflowEntrypoint binding. Each is the same small bit of envelope-and-unwrap glue between a static binding and a dynamic one.

That direction is not limited to Workflows. Every binding Workers exposes is being considered for a dynamic counterpart: queues with per-producer handlers, caches, databases, object stores, AI bindings, and MCP servers per tenant. Whatever can be bound statically is headed toward dispatch-by-tenant, per-agent, per-request, at zero idle cost.

The economic implications for multi-tenant platforms are substantial. Serving customers previously required per-customer containers, databases, disks, and schedulers, stitched together with orchestration and billing complexity. With isolate-level multi-tenancy on dynamic primitives, idle tenants cost approximately nothing, and active tenants share the same hardware. Platforms that once scaled to thousands of paying customers can now reasonably serve tens of millions.

What changes for builders

Agents that produce runnable plans

Coding agents such as OpenCode, Claude Code, Codex, and Pi have shown that LLMs excel at writing code but struggle with sequential tool calls. The Cloudflare Agents SDK and Project Think addressed this through durable execution, using fibers and sub-agents so that long-running agent plans tolerate crashes, hibernation, and redeploys transparently. Dynamic Workflows now makes that plan a first-class Cloudflare Workflow that the agent writes and the platform executes with full durability.

A run(event, step) function generated moments earlier by a model becomes real infrastructure: each step.do(...) is independently retryable, step.sleep('24 hours') hibernates without cost, and step.waitForEvent(...) blocks indefinitely for human approval. Neither the agent nor the platform needs to foresee the plan's shape in advance.

Frameworks that stay out of the customer's way

For platforms where users supply the run(event, step) function—workflow builders, visual automation tools, per-tenant extension systems, low-code environments—Dynamic Workflows is the primitive that removes the usual compromises. A single call to wrapWorkflowBinding({ tenantId }) hands the binding to user code as WORKFLOWS, with every instance automatically tagged, routed to the correct tenant, and executed inside their sandbox. The framework keeps ownership of the Worker Loader; the user owns the workflow logic.

CI/CD without the VM ceremony

Every CI/CD platform is essentially a dispatcher for per-repository configuration: steps in order, secrets, caches, artifacts. Each repository has its own pipeline, branches add variants, and pull requests spawn instances that must run to completion, survive machine failure, retry flaky steps, stream logs, pause for approvals, and persist results. That is precisely the shape of a durable workflow. What was missing was a cloud primitive where the workflow differs per repo, dispatched at runtime with zero provisioning overhead.

With the workflow shipped alongside customer code—for instance in .cloudflare/ci.ts—the dispatcher consumes a webhook, identifies the repository, loads that repo's CIPipeline as a Dynamic Worker, and hands off to Dynamic Workflows. The platform never inspects the pipeline's contents; it merely runs a durable function that lives in the customer's repository. Platform-provided glue such as runInSandbox(), summarise(), and GitHub bindings completes the picture:

import { WorkflowEntrypoint } from 'cloudflare:workers';

export class CIPipeline extends WorkflowEntrypoint {
  async run(event, step) {
    const { repo, sha, branch, pr } = event.payload;

    // Fork an isolated copy of the repo at this commit. Seconds, not minutes.
    const workspace = await step.do('checkout', () =>
      this.env.ARTIFACTS.fork(repo, { sha })
    );

    await step.do('install', () => runInSandbox(workspace, ['pnpm', 'install']));

    // Each parallel step is independently retryable.
    const [lint, test, build] = await Promise.all([
      step.do('lint',  () => runInSandbox(workspace, ['pnpm', 'lint'])),
      step.do('test',  () => runInSandbox(workspace, ['pnpm', 'test'])),
      step.do('build', () => runInSandbox(workspace, ['pnpm', 'build'])),
    ]);

    if (pr) {
      await step.do('comment', () =>
        this.env.GITHUB.commentOnPR(repo, pr, summarise({ lint, test, build }))
      );
    }

    // Workflow hibernates until approval arrives. No VM held open.
    if (branch === 'main') {
      await step.waitForEvent('approval', { type: 'deploy-approval', timeout: '24 hours' });
      await step.do('deploy', () => runInSandbox(workspace, ['pnpm', 'deploy']));
    }
  }
}

Each component in the stack handles a distinct slice:

  • Artifacts supplies every repository with a Git-native, versioned filesystem on Cloudflare's edge. ArtifactFS hydrates lazily, so even multi-GB repositories are usable within seconds, and fork() gives each CI run an isolated copy without a git clone penalty.
  • Dynamic Workers run lightweight steps—lint, formatting, typechecking, bundling—in sandboxed isolates that boot in milliseconds on the same machine as the repo's data. No VM provisioning or image pulls.
  • Dynamic Workflows ties the run together: retryable durable steps, free hibernation during approval waits, and state that survives deploys, evictions, and crashes.
  • Sandboxes cover heavy workloads—docker build, integration suites with Postgres, multi-core Rust compiles—with R2 snapshots that bring warm starts down to a couple of seconds.

The contrast with conventional CI is stark. A traditional run for a mid-sized JavaScript repository spends 15–30 seconds allocating a VM, ten seconds pulling a base image, ten more cloning the repo, and 30–60 seconds on npm ci before a single test executes—all while the full VM cost accrues. The equivalent here forks the repo at the edge in seconds, boots isolates or snapshot-restored sandboxes for each step in milliseconds, runs the actual work, then hibernates. Nothing cold-starts, nothing is provisioned ahead of time, and nothing stays warm. The compute moves to the repository rather than the other way around.

Availability

@cloudflare/dynamic-workflows is MIT-licensed on npm today:

npm install @cloudflare/dynamic-workflows

It depends on Dynamic Workers, currently in open beta on the Workers Paid plan. The repository includes a working example: an interactive browser playground where you define a TenantWorkflow class, press Run, and observe step execution with live logs and a checklist that marks each step.do() commit. The team is active in the Cloudflare Developers Discord for feedback and questions.