Introducing Dynamic Workflows: durable execution that follows the tenant
Captured source
source ↗Introducing Dynamic Workflows: durable execution that follows the tenant Introducing Dynamic Workflows: durable execution that follows the tenant 2026-05-01 Dan Lapid
Luís Duarte
9 min read This post is also available in 日本語 and 한국어 . When we first launched Workers eight years ago, it was a direct-to-developers platform. Over the years, we have expanded and scaled the ecosystem so that platforms could not only build on Workers directly, but they could also enable their customers to ship code to us through many multi-tenant applications. We now see on Workers: Applications where users describe what they want, and the AI writes the implementation. Multi-tenant SaaS where every customer's business logic is, at runtime, some TypeScript the platform has never seen before. Agents that write and run their own tools. CI/CD products where every repo defines its own pipeline. Last month, when we shipped the Dynamic Workers open beta , we gave those platforms a clean primitive for the compute side: hand the Workers runtime some code at runtime, get back an isolated, sandboxed Worker, on the same machine, in single-digit milliseconds. Durable Object Facets extended the same idea to storage — each dynamically-loaded app can have its own SQLite database, spun up on demand, with the platform sitting in front, as a supervisor. Artifacts did the same for source control : a Git-native, versioned filesystem you can create by the tens of millions, one per agent, one per session, one per tenant. So, we have dynamic deployment for storage and source control. What’s next? Today, we are bridging durable execution and dynamic deployment with Dynamic Workflows .
The gap between durable and dynamic execution
Cloudflare Workflows is our durable execution engine. It turns a run(event, step) function into a program where every step survives failures, can sleep for hours or days, can wait for external events, and resumes exactly where it left off when the isolate is recycled. It's the right primitive for anything that has to "keep going" past a single request: onboarding flows, video transcoding pipelines, multi-stage billing, long-running agent loops, and — as of Workflows V2 — up to 50,000 concurrent instances and 300 new instances per second per account, redesigned for the agentic era. But Workflows has always had one assumption baked in: the workflow code is part of your deployment. Your wrangler.jsonc has a block that says "when the engine calls into WORKFLOWS , run the class called MyWorkflow ." One binding, one class. Per deploy. That works fine if you own all the code. It's fine if you're running a traditional application. It stops working the moment you want to let your customer ship their workflow. Say you're building an app platform where the AI writes TypeScript for every tenant. Say you're running a CI/CD product where each repository has its own pipeline. Say you're using an agents SDK where each agent writes its own durable plan. In every one of these cases, the workflow is different for every tenant, every agent, every request. There is no single class to bind. This is the same shape of problem that Dynamic Workers solved for compute and that Durable Object Facets solved for storage. We just hadn't solved it for durable execution yet.
Dynamic Workflows
@cloudflare/dynamic-workflows is a small library. Roughly 300 lines of TypeScript. It lets a single Worker — the Worker Loader — route every create() call to a different tenant's code, and, critically, have the Workflows engine dispatch run(event, step) back to that same code when the workflow actually executes, seconds or hours or days later. Here's the whole pattern. A 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 }) }, })); }
// Register this as class_name in wrangler.jsonc. export const DynamicWorkflow = createDynamicWorkflowEntrypoint( 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); }, }; Add to your wrangler.jsonc :
"workflows": [ { "name": "dynamic-workflow", "binding": "WORKFLOW", "class_name": "DynamicWorkflow" } ] The tenant writes plain, idiomatic Workflows code. They have no idea they're being dispatched:
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 }); }, }; That's it. The tenant calls env.WORKFLOWS.create(...) against what looks like a perfectly normal Workflow binding. Workflow IDs, .status() , .pause() , retries, hibernation, durable steps, step.sleep('24 hours') , step.waitForEvent() — everything works the way it always has. The library handles one thing: making sure that when the Workflows engine eventually wakes up and calls run(event, step) , it ends up inside the right tenant's code.
How it works
Three layers: the Workflows engine (platform) on top, your Worker Loader in the middle, your tenant's code (a Dynamic Worker) on the bottom.
When a request reaches the Worker Loader, it routes the execution to the correct dynamic code on the fly. The rest of the execution is a handoff between these three layers, left-to-right in time: the request enters, bounces up to the engine, is persisted, and later bounces back down again. Walking the flow: ① → ② Entering the tenant's code. The Worker Loader receives an HTTP request, figures out which tenant it's for, loads that tenant's code via the Worker Loader, and forwards the request to its default.fetch . The env it hands the tenant…
Excerpt shown — open the source for the full document.
Notability
notability 5.0/10Cloudflare feature announcement, low traction