Back to Home
Introducing Dynamic Workflows: durable execution that follows the tenant

Introducing Dynamic Workflows: durable execution that follows the tenant

B
Blizine Admin
·1 min read·0 views

Introducing Dynamic Workflows: durable execution that follows the tenant

Introducing Dynamic Workflows: durable execution that follows the tenant Introducing Dynamic Workflows: durable execution that follows the tenant2026-05-01Dan LapidLuís Duarte9 min readThis 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 contains WORKFLOWS: wrapWorkflowBinding({ tenantId }). As far as the tenant is concerned, that looks and acts like a real Workflow binding.③ Up to the Worker Loader. When the tenant calls env.WORKFLOWS.create({ params }), it's actually making a Remote Procedure Call (RPC) into the Worker Loader — the wrapped binding is a WorkerEntrypoint subclass (DynamicWorkflowBinding) that the runtime specialized with the tenant's metadata at load time. That's why you have to export { DynamicWorkflowBinding } from your Worker Loader: the runtime builds per-tenant stubs by looking the class up in cloudflare:workers exports. Bindings that cross the Dynamic Worker boundary have to be RPC stubs — a plain { create, get } object can't be structured-cloned, and the raw Workflow binding isn't serializable either.Inside the Worker Loader, the wrapped binding transparently rewrites the payload: tenant calls: create({ params: { name: 'Alice' } }) │ ▼ engine sees: create({ params: { __workerLoaderMetadata: { tenantId: 't-42' }, params: { name: 'Alice' } }})

④ Up to the engine. The Worker Loader then calls .create() on the real WORKFLOWS binding with the envelope as the params. From here the Workflows engine takes over. It persists event.payload — which now includes the envelope — and schedules the run. Every time the engine later wakes up the workflow (whether that’s after a 24-hour sleep, a crash, or a deploy), the metadata rides along with the payload, waiting to route the run.One implication: treat the metadata as a routing hint, not as authorization. The tenant can read it back via instance.status(). Don't put secrets in there.⑤ → ⑥ The engine comes back down. When the engine is ready to run a step, it calls .run(event, step) on the class you registered in wrangler.jsonc — the one createDynamicWorkflowEntrypoint gave you. That 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.The callback is where everything interesting happens, and it's entirely yours. Fetch the tenant's latest source from R2. Check their plan tier and pick a region. Attach a tail Worker for per-tenant logging. Bundle TypeScript on the fly with @cloudflare/worker-bundler. In the common case, you just hand off 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 that runs many steps over many hours reuses the same dynamic Worker across them. When the isolate eventually gets evicted, the next step.do() pulls the code again and keeps going — the tenant's workflow has no idea anything happened. A Dynamic Worker boots in single-digit milliseconds using a few megabytes of memory, so the dispatch overhead is essentially free. 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. The escape hatch If you want to subclass WorkflowEntrypo

📰Originally published at blog.cloudflare.com

Comments