Dynamic Workers

API reference

import { Type, MetaInfo } from "~/components";

This page describes the Worker Loader binding API, assuming you have configured a binding as env.LOADER.

load

env.LOADER.load(code )

Loads a Worker from the provided WorkerCode and returns a WorkerStub which may be used to invoke the Worker.

Unlike get(), load() does not cache by ID. Each call creates a new Worker.

Use load() when the code is always new, such as for one-time AI-generated tool calls.

get

env.LOADER.get(id , getCodeCallback ):

Loads a Worker with the given ID, returning a WorkerStub which may be used to invoke the Worker.

The loader caches isolates by ID. When the loader sees an ID for the first time, it creates a new isolate. The isolate may stay warm in memory for subsequent requests. If a later call uses the same ID, the runtime may return the existing isolate instead of creating a new one. However, this is not guaranteed. A later call with the same ID may start a new isolate from scratch.

When the runtime needs to start a new isolate and does not have a cached copy of the code, it invokes codeCallback to get the Worker code. This is an async callback, so the application can load the code from remote storage if needed. The callback returns a WorkerCode object.

Because of caching, ensure that the callback always returns exactly the same content for the same ID. If anything about the content changes, you must use a new ID. If the content has not changed, reuse the same ID to take advantage of caching. If the WorkerCode is different every time, pass a random ID.

For example, use IDs of the form <worker-name>:<version-number>, where the version number increments every time the code changes. Alternatively, compute IDs based on a hash of the code and config so that any change produces a new ID.

get() returns a WorkerStub, which you can use to send requests to the loaded Worker. The stub is returned synchronously, so you do not have to await it. If the Worker is not loaded yet, requests made to the stub wait for the Worker to load before being delivered. If loading fails, the request throws an exception.

Two requests are never guaranteed to reach the same isolate. Even if you use the same WorkerStub to make multiple requests, they could execute in different isolates. The callback passed to loader.get() could run any number of times, although it is unusual for it to run more than once.

WorkerCode

This is the structure returned by getCodeCallback to represent a worker.

compatibilityDate

The compatibility date for the Worker. This has the same meaning as the compatibility_date setting in a Wrangler config file.

compatibilityFlags

An optional list of compatibility flags augmenting the compatibility date. This has the same meaning as the compatibility_flags setting in a Wrangler config file.

allowExperimental

If true, experimental compatibility flags are permitted in compatibilityFlags. To set this, the Worker calling the loader must itself have the "experimental" compatibility flag set. Experimental flags cannot be enabled in production.

mainModule

The name of the Worker's main module. This must be one of the modules listed in modules.

modules

A dictionary object mapping module names to their string contents. If the module content is a plain string, the module name must have a file extension indicating its type: either .js or .py.

You can also specify a module's content as an object to set its type independent of the name. The allowed objects are:

:::caution[Warning]

While Dynamic Workers support Python, Python Workers are currently much slower to start than JavaScript Workers. For one-off AI-generated code, we strongly recommend using JavaScript. AI can write either language equally well.

:::

globalOutbound

Controls whether the dynamic Worker has access to the network. The global fetch() and connect() functions (for making HTTP requests and TCP connections, respectively) can be blocked or redirected to isolate the Worker.

If globalOutbound is not specified, the default is to inherit the parent's network access, which usually means the dynamic Worker has full access to the public Internet.

If globalOutbound is null, the dynamic Worker is fully cut off from the network. Both fetch() and connect() throw exceptions.

You can also set globalOutbound to any service binding, including service bindings in the parent Worker's env and loopback bindings from ctx.exports.

Using ctx.exports is particularly useful because it lets you customize the binding for a specific sandbox by setting the value of ctx.props that is passed back to it. The props can contain information to identify the specific dynamic Worker that made the request.

For example:

import { WorkerEntrypoint } from "cloudflare:workers";

export class Greeter extends WorkerEntrypoint {
	fetch(request) {
		return new Response(`Hello, ${this.ctx.props.name}!`);
	}
}

export default {
	async fetch(request, env, ctx) {
		let worker = env.LOADER.get("alice", () => {
			return {
				// Redirect the worker's global outbound to send all requests
				// to the `Greeter` class, filling in `ctx.props.name` with
				// the name "Alice", so that it always responds "Hello, Alice!".
				globalOutbound: ctx.exports.Greeter({ props: { name: "Alice" } }),

				// ... code ...
			};
		});

		return worker.getEntrypoint().fetch(request);
	},
};

env

The environment object to provide to the dynamic Worker.

Using this, you can provide custom bindings to the Worker.

env is serialized and transferred into the dynamic Worker, where it becomes the value of env. It may contain:

The second point is the key to creating custom bindings: you can define a binding with any arbitrary API, by defining a WorkerEntrypoint class implementing an RPC API, and then giving it to the dynamic Worker as a Service Binding.

Moreover, by using ctx.exports loopback bindings, you can further customize the bindings for the specific dynamic Worker by setting ctx.props, as described for globalOutbound.

import { WorkerEntrypoint } from "cloudflare:workers";

// Implement a binding which can be called by the dynamic Worker.
export class Greeter extends WorkerEntrypoint {
	greet() {
		return `Hello, ${this.ctx.props.name}!`;
	}
}

export default {
	async fetch(request, env, ctx) {
		let worker = env.LOADER.get("alice", () => {
			return {
				env: {
					// Provide a binding which has a method greet() which can be called
					// to receive a greeting. The binding knows the Worker's name.
					GREETER: ctx.exports.Greeter({ props: { name: "Alice" } }),
				},

				// ... code ...
			};
		});

		return worker.getEntrypoint().fetch(request);
	},
};

tails

Specify one or more Tail Workers to observe console logs, errors, and other details about the dynamically-loaded Worker's execution. A tail event is delivered to the Tail Worker when a request to the dynamically-loaded Worker completes. You can implement the Tail Worker as an alternative entrypoint in your parent Worker, referencing it with ctx.exports:

import { WorkerEntrypoint } from "cloudflare:workers";

export default {
	async fetch(request, env, ctx) {
		let worker = env.LOADER.get("alice", () => {
			return {
				// Send logs, errors, etc. to `LogTailer`. We pass `name` in the
				// `ctx.props` so that `LogTailer` knows what generated the logs.
				// (You can pass anything you want in `props`.)
				tails: [ctx.exports.LogTailer({ props: { name: "alice" } })],

				// ... code ...
			};
		});

		return worker.getEntrypoint().fetch(request);
	},
};

export class LogTailer extends WorkerEntrypoint {
	async tail(events) {
		let name = this.ctx.props.name;

		// Send the logs off to our log endpoint, specifying the worker name in
		// the URL.
		//
		// Note that `events` will always be an array of size 1 in this scenario,
		// describing the event delivered to the dynamically-loaded Worker.
		await fetch(`https://example.com/submit-logs/${name}`, {
			method: "POST",
			body: JSON.stringify(events),
		});
	}
}

Getting started

import { WranglerConfig, TypeScriptExample } from "~/components";

You can create a Worker that spins up other Workers, called Dynamic Workers, at runtime to execute code on-demand in a secure, sandboxed environment. You provide the code, choose which bindings the Dynamic Worker can access, and control whether the Dynamic Worker can reach the network.

Dynamic Workers support two loading modes:

load() is best for one-time code execution, for example when using Code Mode. get(id, callback) is better when the same code will receive subsequent requests, for example when you are building applications.

Try it out

Dynamic Workers Starter

Deploy to Workers

Use this "hello world" starter to get a Worker deployed that can load and execute Dynamic Workers.

Dynamic Workers Playground

Deploy to Workers

You can also deploy the Dynamic Workers Playground, where you can write or import code, bundle it at runtime with @cloudflare/worker-bundler, execute it through a Dynamic Worker, and see real-time responses and execution logs.

Configure Worker Loader

For a Worker to create Dynamic Workers, it needs a Worker Loader binding. Unlike most Workers bindings, this binding does not point at any external resource. It provides access to the Worker Loader API.

Add the following to your Worker's Wrangler configuration:

{
	"worker_loaders": [
		{
			"binding": "LOADER",
		},
	],
}

Your Worker will then have access to the Worker Loader API via env.LOADER.

Run a Dynamic Worker

Use env.LOADER.load() to create a Dynamic Worker and run it:

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		// Load a worker.
		const worker = env.LOADER.load({
			compatibilityDate: "$today",

			mainModule: "src/index.js",
			modules: {
				"src/index.js": `
					export default {
						fetch(request) {
							return new Response("Hello from a dynamic Worker");
						},
					};
				`,
			},

			// Block all outbound network access from the Dynamic Worker.
			globalOutbound: null,
		});

		// Get the Dynamic Worker's `export default` entrypoint.
		// (A Worker can also export separate, named entrypoints.)
		let entrypoint = worker.getEntrypoint();

		// Forward the HTTP request to it.
		return entrypoint.fetch(request);
	},
};

In this example, env.LOADER.load() creates a Dynamic Worker from the code defined in modules and returns a stub that represents it.

worker.getEntrypoint().fetch(request) sends the incoming request to the Dynamic Worker's fetch() handler, which processes it and returns a response.

Reusing a Dynamic Worker across requests

If you expect to load the exact same Worker more than once, use get(id, callback) instead of load(). The id should be a unique string identifying the particular code you intend to load. When the runtime sees the same id again, it can reuse the existing Worker instead of creating a new one, if it has not been evicted yet.

The callback you provide only runs if the Worker is not already loaded. This lets you skip loading the code from storage when the Worker is already running.

const worker = env.LOADER.get("hello-v1", async () => {
	// Callback only runs if there is not already a warm
	// instance available.

	// Load code from storage.
	let code = await env.MY_CODE_STORAGE.get("hello-v1");

	// Return the same format as `env.LOADER.load()` accepts.
	return {
		compatibilityDate: "$today",
		mainModule: "index.js",
		modules: { "index.js": code, },
		globalOutbound: null,
	};
});

Supported languages

Dynamic Workers support JavaScript (ES modules and CommonJS), Python, and WebAssembly (Wasm) modules. Pass JavaScript and Python code as strings in the modules object. Pass compiled Wasm binaries as { wasm: ArrayBuffer } module objects.

There is no build step, so languages like TypeScript must be compiled to JavaScript before being passed to load() or get().

For the full list of supported module types, refer to the API reference.

Python Workers

To run Python code in a Dynamic Worker, you must include the python_workers compatibility flag. Without this flag, the Dynamic Worker will fail to load the Python runtime.

const worker = env.LOADER.load({
  compatibilityDate: "$today",
  compatibilityFlags: ["python_workers"],
  mainModule: "worker.py",
  modules: {
    "worker.py": `
from workers import Response, WorkerEntrypoint

class Default(WorkerEntrypoint):
    async def fetch(self, request):
        return Response("Hello from Python!")
    `,
  },
});

Using TypeScript and npm dependencies

If your Dynamic Worker needs TypeScript compilation or npm dependencies, the code must be transpiled and bundled before passing to the Worker Loader.

@cloudflare/worker-bundler is a library that handles this for you. Use it to bundle source files into a format that load() and get() accept:

```ts import { createWorker } from "@cloudflare/worker-bundler";

const worker = env.LOADER.get("my-worker", async () => { const { mainModule, modules } = await createWorker({ files: { "src/index.ts": import { Hono } from 'hono'; const app = new Hono(); app.get('/', (c) => c.text('Hello from Hono!')); export default app;, "package.json": JSON.stringify({ dependencies: { hono: "^4.0.0" }, }), }, });

return { mainModule, modules, compatibilityDate: "$today" };

});

</TypeScriptExample>

`createWorker()` handles TypeScript compilation, dependency resolution from npm, and bundling. It returns `mainModule` and `modules` ready to pass directly to `load()` or `get()`.

Dynamic Workers

import { Description } from "~/components";

Spin up Workers at runtime to execute code on-demand in a secure, sandboxed environment.

Dynamic Workers let you spin up an unlimited number of Workers to execute arbitrary code specified at runtime. Use Dynamic Workers as a lightweight alternative to containers for securely sandboxing code you do not trust.

Dynamic Workers are the lowest-level primitive for spinning up a Worker, giving you full control over defining how the Worker is composed, which bindings it receives, whether it can reach the network, and more.

Get started

Deploy the Dynamic Workers Playground to create and run Workers dynamically from code you write or import from GitHub, with real-time logs and observability.

Deploy to Cloudflare

Use cases

Use Dynamic Workers when code needs to run quickly in a secure, isolated environment.

Features

Because you compose the Worker that runs the code at runtime, you control how that Worker is configured and what it can access.

Pricing

Dynamic Workers pricing is based on three dimensions: Dynamic Workers created daily, requests, and CPU time.

Dynamic Workers are currently only available on the Workers Paid plan.

Included Additional usage
Dynamic Workers created daily 1,000 unique Dynamic Workers per month +$0.002 per Dynamic Worker per day
Requests ¹ 10 million per month +$0.30 per million requests
CPU time ¹ 30 million CPU milliseconds per month +$0.02 per million CPU milliseconds

¹ Uses Workers Standard rates and will appear as part of your existing Workers bill, not as separate Dynamic Workers charges.

:::note[Billing]

Starting May 26, 2026, Dynamic Workers created daily are billed as part of Dynamic Workers pricing.

Dynamic Workers requests and CPU time are also billed as part of your Workers plan. They count toward your Workers requests and CPU usage.

:::

Dynamic Workers created daily

You are billed for each unique Dynamic Worker created in a day. A Dynamic Worker is uniquely identified by its Worker ID and code — if either changes, it counts as a new Dynamic Worker. The count resets daily.

Scenario Counted as
Same code, same ID, invoked multiple times 1 Dynamic Worker
Same code, different IDs 1 Dynamic Worker per ID
Same ID, different code versions 1 Dynamic Worker per code version
No ID provided or .load(code) used 1 Dynamic Worker per invocation

:::note If your application sends multiple requests to the same Worker, use .get() with a stable ID to avoid being billed for multiple creations. :::

View Dynamic Workers usage

To view the number of billable Dynamic Workers invoked during your billing period, go to Workers & Pages > Overview in the Cloudflare dashboard.

Dynamic Workers usage data only goes back to June 1, 2026.

You can also query this count through the GraphQL Analytics API by using workersInvocationsByOwnerAndScriptGroups and selecting distinctDynamicWorkerCount:

query getDynamicWorkersCount(
	$accountTag: string!
	$filter: AccountWorkersInvocationsByOwnerAndScriptGroupsFilter_InputObject
) {
	viewer {
		accounts(filter: { accountTag: $accountTag }) {
			workersInvocationsByOwnerAndScriptGroups(limit: 10000, filter: $filter) {
				uniq {
					distinctDynamicWorkerCount
				}
			}
		}
	}
}

Use variables to set the account and billing-period date range:

{
	"accountTag": "<ACCOUNT_ID>",
	"filter": {
		"date_geq": "2026-06-01",
		"date_leq": "2026-06-30"
	}
}

The distinctDynamicWorkerCount field returns the unique Dynamic Workers count for the selected period.

Requests

Dynamic Workers reuse Workers Standard request pricing.

A request is counted each time a Dynamic Worker is invoked:

If an RPC method returns a stub (an object that extends RpcTarget), those returned stubs share the same RPC session as the original call. Subsequent calls on the returned stub are not billed as separate requests.

CPU time

CPU time is billed at the same rate as Workers Standard.

Unlike standard Workers (where only execution time is billed), Dynamic Workers bill for two components of CPU time:

Code Mode Example

import { TypeScriptExample } from "~/components";

This example shows how to use the @cloudflare/codemode library with the Agents SDK to build an agent where the LLM writes code to orchestrate tool calls, instead of calling them one at a time. This approach, called Code Mode, reduces tokens spent by up to 80%, returns better results, and avoids bloating the context window.

Deploy to Workers

This example shows you how to:

How it works

The agent uses three components from the @cloudflare/codemode library and the Agents SDK:

The flow:

  1. User sends a message over WebSocket.
  2. AIChatAgent passes it to the LLM with a single tool available: codemode.
  3. The LLM writes JavaScript, for example const projects = await codemode.listProjects(), instead of making individual tool calls.
  4. DynamicWorkerExecutor spins up an isolated Worker and runs the code. Inside the sandbox, codemode.listProjects() calls your real listProjects implementation.
  5. The result, any console output, and errors are returned to the LLM.
  6. The LLM uses the result to respond to the user, or writes more code if it needs to.

DynamicWorkerExecutor

DynamicWorkerExecutor is part of the @cloudflare/codemode library. When the LLM writes code that orchestrates your tools, that code needs to run somewhere safe. DynamicWorkerExecutor spins up an isolated Dynamic Worker for each execution using the Worker Loader binding. Inside the sandbox:

import { DynamicWorkerExecutor } from "@cloudflare/codemode";

const executor = new DynamicWorkerExecutor({
	loader: env.LOADER, // WorkerLoader binding from wrangler.jsonc
	timeout: 30000, // default: 30s
	globalOutbound: null, // null = fetch blocked
});

createCodeTool

createCodeTool is part of @cloudflare/codemode. It takes your tools and an executor, and returns a single AI SDK tool(). It:

import { createCodeTool } from "@cloudflare/codemode/ai";

const codemode = createCodeTool({
	tools: myTools, // Record<string, tool()> with Zod schemas
	executor, // DynamicWorkerExecutor
});

// The LLM sees: one tool called "codemode" with input { code: string }
// The description includes TypeScript types for all your tools

The LLM writes an async arrow function. createCodeTool normalizes it and hands it to the executor. The executor builds a Worker module with a codemode proxy, runs the code, and returns { code, result, logs }.

Dynamic Workers Playground

import { Render, TypeScriptExample } from "~/components";

Try the Dynamic Workers playground to write or import code from GitHub, bundle it at runtime, execute it in a Dynamic Worker, and view real-time logs.

Deploy to Workers

Dynamic Workers Playground UI

What this demo shows

Bundling code at runtime

The playground uses @cloudflare/worker-bundler to compile TypeScript, resolve npm dependencies, and produce modules the Worker Loader can execute.

Pass source files and a package.json to createWorker(), which resolves dependencies and returns bundled modules ready to load as a Dynamic Worker:

import { createWorker } from "@cloudflare/worker-bundler";

const { mainModule, modules, warnings } = await createWorker({
	files: {
		"src/index.ts": userCode,
		"package.json": JSON.stringify({
			dependencies: { hono: "^4.0.0" },
		}),
	},
	bundle: true,
	minify: false,
});

Caching Dynamic Workers

env.LOADER.load() creates a new Dynamic Worker on every call. To avoid re-bundling unchanged code, use env.LOADER.get(id, callback) instead. The runtime returns an existing Worker on a cache hit, or calls your callback to build one on a miss:

const worker = env.LOADER.get(workerId, async () => {
	// This callback only runs on cache miss
	const { mainModule, modules } = await createWorker({ files });

	return {
		mainModule,
		modules,
		compatibilityDate: "2026-05-01",
		tails: [
			contextExports.DynamicWorkerTail({ props: { workerId } }),
		],
	};
});

const response = await worker.getEntrypoint().fetch(request);

In the playground, you can see this in action — run the same Dynamic Worker twice and the second request shows a cached result with 0ms cold start, since the build and load phases are skipped entirely.

Observability with Tail Workers

When you run code in the playground, console output from the Dynamic Worker streams back to the browser in real time. Under the hood, this works through a Tail Worker pipeline:

  1. A Tail Worker (DynamicWorkerTail) captures console.log output from the Dynamic Worker.
  2. Logs are forwarded to a LogSession Durable Object.
  3. The Durable Object streams them to the client over WebSocket.

To wire this up, include the Tail Worker in the tails array when creating the Dynamic Worker:

const worker = env.LOADER.get(workerId, async () => ({
	mainModule,
	modules,
	compatibilityDate: "2026-05-01",
	tails: [contextExports.DynamicWorkerTail({ props: { workerId } })],
}));

For more information on how to capture and stream logs from Dynamic Workers, refer to Observability with Dynamic Workers.

Running locally

Clone the repo and start the dev server:

npm install
npm run dev

Dynamic Workers Starter

import { Render, TypeScriptExample, WranglerConfig } from "~/components";

A starter template for deploying a Worker that loads and runs Dynamic Workers.

Deploy to Workers

What it does

This template demonstrates how to use the Worker Loader API to execute code at runtime. The host Worker exposes an /api/run endpoint that accepts code from the frontend, loads it into a sandboxed Dynamic Worker, and returns the result.

Use this pattern for AI agents that need to execute a snippet of code to complete an action.

Configuration

Add a worker_loaders binding to your Wrangler file:

{
	"worker_loaders": [
		{
			"binding": "LOADER"
		}
	]
}

Loading and executing a Dynamic Worker

In this example:

export default {
	async fetch(request, env): Promise<Response> {
		const { code } = await request.json();

		const worker = env.LOADER.load({
			compatibilityDate: "2026-05-01",
			mainModule: "worker.js",
			modules: {
				"worker.js": code,
			},
			// Block all outbound network access
			globalOutbound: null,
		});

		const result = await worker.getEntrypoint().fetch(request);
		return result;
	},
} satisfies ExportedHandler;

Running locally

npm install
npm run dev

Dynamic Workflows Playground

Try the dynamic workflows playground, write workflow logic in JavaScript, execute it from a Dynamic Worker, and log every step in real time.

This example shows you how to run Cloudflare Workflows from a Dynamic Worker to get full durable execution, including step retries, sleep, hibernation, and waitForEvent, for any workflow you need to run on demand.

Deploy to Workers

How it works

There are two parts:

The @cloudflare/dynamic-workflows library connects the two. When the Dynamic Worker creates a workflow, the library tags it with information about which Dynamic Worker created it. That tag is persisted by the Workflows engine, so when a workflow needs to resume after a sleep, a failure, or a server restart, the engine knows which Dynamic Worker to reload to continue execution.

For a full walkthrough of the library and how to set it up, refer to the Dynamic Workflows guide.

What this playground includes

Examples

import { DirectoryListing } from "~/components";

These examples show how teams use Dynamic Workers to run code safely, bundle dependencies, and return results to an application or agent.

Platform

import { DirectoryListing } from "~/components";

Limits

Cloudflare limits the number of distinct Dynamic Workers with in-flight requests. Multiple in-flight requests to the same Dynamic Worker count as one toward this limit.

Context Concurrent Dynamic Workers
Worker request 4
Durable Object 10 (previously 4)

In a Worker, each request has its own input/output (I/O) context. Each request can therefore have up to four distinct Dynamic Workers with in-flight requests.

A Durable Object shares one I/O context across all concurrent requests to the same object. Those requests can collectively have up to ten distinct Dynamic Workers with in-flight requests. To set lower CPU time or subrequest limits, refer to Custom resource limits.

Bindings

import { WranglerConfig } from "~/components";

Bindings let you control what a Dynamic Worker can access. When you create a Dynamic Worker, you decide exactly what resources and operations it can use.

This allows you to:

Custom bindings with Dynamic Workers

With custom bindings, you:

How it works

Step 1: Define the binding

To create a custom binding, your loader Worker needs to implement a WorkerEntrypoint class and export it. The methods you define on this class are the methods the Dynamic Worker can call.

import { WorkerEntrypoint } from "cloudflare:workers";

export class ChatRoom extends WorkerEntrypoint {
  async post(text: string): Promise<void> {
    // Your implementation here
  }
}

Step 2: Pass it to the Dynamic Worker

Your loader Worker then creates an instance of the exported class, called a stub, and passes it into the Dynamic Worker's env.

let chatRoom = ctx.exports.ChatRoom({ props: { roomName: "#bot-chat" } });

let worker = env.LOADER.load({
  env: { CHAT_ROOM: chatRoom },
  // ...
});

From the Dynamic Worker's perspective, CHAT_ROOM looks like a regular binding with methods it can call:

// Inside the Dynamic Worker
await this.env.CHAT_ROOM.post("Hello!");

Step 3: Customize per user with props

One class can serve many different Dynamic Workers. Instead of defining a separate class for each user, pass in props when you create the stub. props contains information specific to that user.

// Same class, different props per user
let aliceRoom = ctx.exports.ChatRoom({ props: { roomName: "#alice", apiKey: ALICE_KEY } });
let bobRoom   = ctx.exports.ChatRoom({ props: { roomName: "#bob", apiKey: BOB_KEY } });

When the Dynamic Worker calls a method on the binding, it makes a call back to your loader Worker, where the method runs. Inside that method, you can read the props through this.ctx.props. Only the loader Worker has access to the props. The Dynamic Worker never sees them.

export class ChatRoom extends WorkerEntrypoint<Cloudflare.Env, ChatRoomProps> {
  async post(text: string): Promise<void> {
    // Props are set when the stub is created — the Dynamic Worker never sees them
    let roomName = this.ctx.props.roomName;
    await postToChat(roomName, text);
  }
}

Example: Chat room agent

The following example puts it all together. Suppose you are building a platform where AI agents can post to chat rooms. Each agent should only be able to post to its assigned room, and it should never see the API key used to authenticate.

You define a ChatRoom class in your loader Worker. This class has a post method, the only method the Dynamic Worker can call on this binding. Inside this class, you control which room the message goes to, which API key is used, and what name is attached to the message.

import { WorkerEntrypoint } from "cloudflare:workers";

export class ChatRoom extends WorkerEntrypoint<Cloudflare.Env, ChatRoomProps> {
  async post(text: string): Promise<void> {
    let { apiKey, botName, roomName } = this.ctx.props;

    // Prefix the message with the bot's name.
    text = `[${botName}]: ${text}`;

    // Send it to the chat service.
    await postToChat(apiKey, roomName, text);
  }
}

type ChatRoomProps = {
  apiKey: string;
  roomName: string;
  botName: string;
};

You export one ChatRoom class, but each stub you create can have different props, such as a different room name, API key, or bot name. The props are set when you create the stub, and the Dynamic Worker never sees them.

Now pass it to a Dynamic Worker:

// Create a stub scoped to a specific room.
let chatRoom = ctx.exports.ChatRoom({
  props: {
    apiKey,
    roomName: "#bot-chat",
    botName: "Robo",
  },
});

let worker = env.LOADER.load({
  env: {
    CHAT_ROOM: chatRoom,
  },
  compatibilityDate: "$today",
  mainModule: "index.js",
  modules: {
    "index.js": `
      export class Agent extends WorkerEntrypoint {
        async run() {
          // This is all the Dynamic Worker sees.
          await this.env.CHAT_ROOM.post("Hello!");
        }
      }
    `,
  },
  globalOutbound: null,
});

return worker.getEntrypoint("Agent").run();

The agent calls this.env.CHAT_ROOM.post("Hello!"). It has no way to post to a different room, see or use the API key, or change the bot name attached to its messages.

:::tip[Tell your agent about the types]

For an AI agent to write code against your bindings, it needs to know the interface. Give your agent TypeScript type declarations with doc comments describing each method. Modern LLMs understand TypeScript well, which makes it the most concise way to describe a JavaScript API. This works even if the agent is writing plain JavaScript.

Make sure your WorkerEntrypoint class extends the TypeScript type so the declarations stay in sync with the implementation.

:::

Passing normal Workers bindings

To pass resources like a KV namespace or R2 bucket to a Dynamic Worker, you need to bind the resource to your loader Worker and create a custom binding that wraps it. You can scope access per customer by prefixing keys and defining only the methods you want to expose.

Example: Scoping a KV namespace per customer

First, bind the KV namespace to your loader Worker. Then in your loader Worker, export a class that uses the KV binding and defines the methods Dynamic Workers can call:

import { WorkerEntrypoint } from "cloudflare:workers";

export class MyStorage extends WorkerEntrypoint<Cloudflare.Env, MyStorageProps> {
  // Export this class from your loader Worker
  // The Dynamic Worker will be able to call get() and put()
  async get(key: string): Promise<string | null> {
    // Prefix the key so this customer can only access their own data
    return this.env.MY_KV.get(`${this.ctx.props.prefix}:${key}`);
  }

  async put(key: string, value: string): Promise<void> {
    await this.env.MY_KV.put(`${this.ctx.props.prefix}:${key}`, value);
  }
}

type MyStorageProps = {
  prefix: string;
};

Then pass it to the Dynamic Worker with a customer-specific prefix:

// Create a stub scoped to this customer's prefix
let storage = ctx.exports.MyStorage({
  props: { prefix: `customer-${customerId}` },
});

let worker = env.LOADER.load({
  env: { STORAGE: storage },
  // ...
});

The Dynamic Worker uses it like any other binding:

// Inside the Dynamic Worker, it sees STORAGE with get and put
let value = await this.env.STORAGE.get("settings");
await this.env.STORAGE.put("settings", "dark-mode");

This same pattern works for any resource your loader Worker has access to, such as R2 buckets or D1 databases. Bind the resource to your loader Worker, export a class that uses it, and pass the stub to the Dynamic Worker.

For persistent storage that lives with each Dynamic Worker, refer to Durable Object Facets.

Capability-based sandboxing

Custom bindings follow a capability-based security model: a Dynamic Worker can only access what you explicitly give it. If it has not received a stub for something, it cannot access it.

This is powered by Workers RPC, also known as Cap'n Web, an RPC system designed to pass object references across security boundaries. When a Dynamic Worker receives a stub, it can call that object's methods and each call is an RPC back to the original object in your loader Worker. Stubs have no global identifier and cannot be forged. The only way to obtain one is to receive it.

Capability-based security is essential to the design of most successful sandboxes, though it is usually hidden as an implementation detail. For example, Android has Binder, Chrome has Mojo, and Cloudflare Workers has Cap'n Web. Dynamic Workers directly expose this power to you, the developer, so that you can build your own strong sandbox.

Durable Object Facets

import { TypeScriptExample, WranglerConfig, Type, MetaInfo, } from "~/components";

Durable Object Facets let you load a Durable Object class from a Dynamic Worker and run it as a child of your own Durable Object. The child (the facet) gets its own isolated SQLite database, while your class acts as a supervisor that controls access.

This is useful when you want dynamically-generated code — for example, code written by an AI agent — to have persistent storage, without giving it direct access to a Durable Object namespace. Your supervisor loads the code, creates the facet, and forwards requests into it. You stay in control of what the dynamic code can do.

Understand the model

A facet-based setup has three layers:

The supervisor's database and the facet's database are stored together as part of the same overall Durable Object. The dynamic code cannot read the supervisor's database — it only has access to its own.

Diagram showing the facet architecture: a request flows through the Worker entry point into a Durable Object instance containing a Supervisor with its own SQLite DB, which creates an isolated Facet with a separate SQLite DB via ctx.facets.get() and forwards requests to it via facet.fetch()

Configure your Worker

Your Worker needs two things: a Durable Object class with a SQLite storage backend, and a Worker Loader binding.

compatibility_date = "$today"
main = "src/index.ts"
upload_source_maps = true

[[migrations]]
tag = "v1"
new_sqlite_classes = ["AppRunner"]

[[worker_loaders]]
binding = "LOADER"

Load and run a dynamic class

The following example shows a supervisor Durable Object (AppRunner) that loads dynamic code, creates a facet from it, and forwards HTTP requests to the facet.

The dynamic code is a simple counter app that tracks how many requests it has received, using its own SQLite-backed storage. In a real application, this code would come from an AI agent or user upload rather than a static string.

import { DurableObject } from "cloudflare:workers";

// In production, this code would come from an AI agent, a database,
// or user input — not a static string.
const AGENT_CODE = `
  import { DurableObject } from "cloudflare:workers";

  export class App extends DurableObject {
    fetch(request) {
      // Note: storage.kv provides simple KV storage backed by SQLite,
			// but you can also use SQL directly via storage.sql. See:
			// https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/

			let counter = this.ctx.storage.kv.get("counter") || 0;
      ++counter;
      this.ctx.storage.kv.put("counter", counter);

      return new Response("You have made " + counter + " requests.\\n");
    }
  }
`;

// AppRunner is your supervisor. Each instance manages one
// dynamically-loaded application.
export class AppRunner extends DurableObject<Env> {
	async fetch(request: Request): Promise<Response> {
		// Get a stub pointing to the "app" facet. If the facet has not
		// started yet (or has hibernated), the callback runs to tell the
		// runtime what code to load.
		const facet = this.ctx.facets.get("app", async () => {
			const worker = this.#loadDynamicWorker();

			// Extract the Durable Object class named "App" from the
			// dynamic Worker's exports.
			const appClass = worker.getDurableObjectClass("App");

			return { class: appClass };
		});

		// Forward the request to the facet.
		// You can also call RPC methods on the stub.
		return await facet.fetch(request);
	}

	#loadDynamicWorker() {
		// Use get() so the Worker stays warm across requests.
		// Each unique code version needs a unique ID.
		const codeId = "agent-code-v1";

		return this.env.LOADER.get(codeId, async () => {
			return {
				compatibilityDate: "$today",
				mainModule: "worker.js",
				modules: { "worker.js": AGENT_CODE },
				globalOutbound: null, // block network access
			};
		});
	}
}

export default {
	async fetch(
		request: Request,
		env: Env,
		ctx: ExecutionContext,
	): Promise<Response> {
		// Look up the AppRunner instance named "my-app".
		const obj = ctx.exports.AppRunner.getByName("my-app");

		// Forward the request to it.
		return await obj.fetch(request);
	},
};

In this example:

this.ctx.facets reference

The this.ctx.facets object is available inside any Durable Object class. It provides methods to create, shut down, and delete facets. A single Durable Object can have any number of facets with different names, each with its own independent SQLite database.

get

this.ctx.facets.get(name , callback )

Creates or resumes a facet with the given name and returns a stub you can use to send it requests.

If the facet has not started yet, or has hibernated, the runtime calls getStartupOptions to determine what code to load. Otherwise, the existing facet is reused and the callback is not invoked. callback can optionally be async (i.e. returning Promise<FacetStartupOptions>).

The returned stub behaves like a Durable Object stub. You can call .fetch() on it to send HTTP requests, or call RPC methods directly.

abort

this.ctx.facets.abort(name , reason )

Shuts down a running facet and invalidates all existing stubs. Any subsequent call on an invalidated stub throws reason. The facet's storage is preserved.

After aborting, you can call get() again to restart the facet — including with a different class. This makes abort() useful for code updates: abort the facet running the old version, then call get() with a callback that returns the new class.

delete

this.ctx.facets.delete(name )

Aborts the facet (if running) and permanently deletes its SQLite database. If you call get() with the same name afterward, the facet starts with an empty database.

Use delete() to clean up storage for facets that are no longer needed.

FacetStartupOptions

The object returned by the getStartupOptions callback.

class

The Durable Object class to instantiate for the facet. Obtain this by calling worker.getDurableObjectClass("ClassName") on a Dynamic Worker stub.

id

The ID the facet sees as its own ctx.id. If omitted, the facet inherits the parent Durable Object's ID.

Isolate storage

The supervisor and each facet have separate SQLite databases. The dynamic code uses the standard Durable Object storage APIs with all operations targeting the facet's own database.

This isolation means you do not need to trust the dynamic code with your supervisor's data. You can store metadata, billing counters, or access-control state in the supervisor's database, and the facet cannot read or modify any of it.

In production, you would typically store the dynamic code itself in the supervisor's database and load it in the #loadDynamicWorker() method. This keeps the code paired with the Durable Object instance that manages it.

Dynamic Workflows

import { PackageManagers, TypeScriptExample, WranglerConfig, } from "~/components";

You can run a Workflow inside a Dynamic Worker to get durable execution for code that is loaded at runtime. Each step in the Workflow survives failures, can sleep for hours or days, can wait for external events, and resumes exactly where it left off — even if the isolate is recycled between steps.

Because Dynamic Workers are created on-demand, you do not have to register each Workflow up front or manage them individually. Load the code when it is needed, and the Workflows engine handles persistence and retries behind the scenes. This works equally well for one-time executions as it does for long-running, multi-step processes.

For example, you might be building:

The @cloudflare/dynamic-workflows library connects your Worker Loader to the Workflows engine so that each Dynamic Worker gets the full power of durable steps (step.do(), step.sleep(), step.waitForEvent()) without you having to build the plumbing yourself.

In this guide, you will use the @cloudflare/dynamic-workflows library to set up a Worker Loader, write a Dynamic Worker with durable steps, and trigger a Workflow instance.

Understand the model

This setup has three parts:

Architecture

Here is how they work together:

The library provides two functions that handle the wiring between the Worker Loader and the Workflows engine, so you do not have to manually tag requests, parse payloads, or write your own WorkflowEntrypoint subclass.

Install the library

The library handles the wiring between the Worker Loader and the Workflows engine, so you do not have to manually tag requests, parse payloads, or write your own WorkflowEntrypoint subclass.

Configure your Worker Loader

Your Worker Loader needs two bindings:

name = "my-worker-loader"
main = "src/index.ts"
upload_source_maps = true
compatibility_date = "$today"

[[worker_loaders]]
binding = "LOADER"

[[workflows]]
name = "dynamic-workflow"
binding = "WORKFLOWS"
class_name = "DynamicWorkflow"

Create the Worker Loader

The Worker Loader is where you connect Dynamic Workers to the Workflows engine. In this file, you define:

:::note

You must re-export DynamicWorkflowBinding from your Worker Loader. The Cloudflare runtime needs this export to build the wrapped binding that Dynamic Workers use. If you forget this line, you will get a runtime error when a Dynamic Worker tries to create a Workflow instance.

:::

import {
	createDynamicWorkflowEntrypoint,
	DynamicWorkflowBinding,
	wrapWorkflowBinding,
	type WorkflowRunner,
} from "@cloudflare/dynamic-workflows";

// Required: re-exporting puts the class on cloudflare:workers exports,
// which is how wrapWorkflowBinding builds per-tenant RPC stubs.
export { DynamicWorkflowBinding };

interface Env {
	WORKFLOWS: Workflow;
	LOADER: WorkerLoader;
}

function loadTenant(env: Env, tenantId: string) {
	return env.LOADER.get(tenantId, async () => ({
		compatibilityDate: "2026-01-01",
		mainModule: "index.js",
		modules: { "index.js": await fetchTenantCode(tenantId) },
		// The Dynamic Worker uses this exactly like a real Workflow binding;
		// every create() is tagged with { tenantId } automatically.
		env: { WORKFLOWS: wrapWorkflowBinding({ tenantId }) },
	}));
}

// The entrypoint name must match `class_name` in the workflows binding of your Wrangler config file.
export const DynamicWorkflow = createDynamicWorkflowEntrypoint<Env>(
	async ({ env, metadata }) => {
		const stub = loadTenant(env, metadata.tenantId as string);
		return stub.getEntrypoint("TenantWorkflow") as unknown as WorkflowRunner;
	},
);

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

Here is what happens when a request arrives:

  1. The fetch handler reads the tenant ID from the request header.
  2. loadTenant calls env.LOADER.get() to load (or reuse) a Dynamic Worker for that tenant. The Dynamic Worker receives WORKFLOWS: wrapWorkflowBinding({ tenantId }) as a binding, which looks and behaves like a normal Workflow binding.
  3. The request is forwarded to the Dynamic Worker's fetch handler, which can now call env.WORKFLOWS.create() to start a Workflow instance.

When that Workflow instance later needs to run a step — for example, after a step.sleep() or when a new isolate picks it up — the Workflows engine calls run() on the DynamicWorkflow class. The library reads the tenantId back from the metadata stored on the instance and invokes the callback you passed to createDynamicWorkflowEntrypoint. That callback loads the Dynamic Worker for that tenant and returns its TenantWorkflow class, so the engine can execute the next step in the original code.

Write the Dynamic Worker

The Dynamic Worker is the code your user writes, and it does not need to know anything about the routing layer. It is a standard Workflow that uses step.do(), step.sleep(), and step.waitForEvent() as normal — from its perspective, env.WORKFLOWS is a regular Workflow binding.

:::caution

Do not put secrets in the metadata you pass to wrapWorkflowBinding (for example, API keys or tokens). The Workflows engine persists the metadata in the event payload, and Dynamic Worker code can read it back via instance.status(). Use metadata for routing information like tenant IDs, not for sensitive data.

:::

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(),
		});
		// instance is an RPC stub — .id is an RpcPromise, so await it.
		return Response.json({ id: await instance.id });
	},
};

Normal Workflows behavior still applies. Workflow IDs, .status(), .pause(), retries, hibernation, and durable steps are unaffected by this architecture. The library only adds the routing between the Worker Loader and the Dynamic Worker.

Trigger a dynamic workflow

Send a POST request to the Worker Loader with a tenant ID header and a JSON payload. The Worker Loader loads the matching Dynamic Worker, which calls env.WORKFLOWS.create() and returns the new instance ID.

curl -X POST http://localhost:8787/ \
  -H "x-tenant-id: tenant-42" \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice"}'

Check workflow status

Use the instance ID returned from the previous request to check the Workflow status. For more information on the status API, refer to the Workers API reference.

curl "http://localhost:8787/api/status?instanceId=YOUR_INSTANCE_ID"

Egress control

When you run untrusted or AI-generated code in a dynamic Worker, you need to control what it can access on the network. Common requirements include:

The globalOutbound option in the WorkerCode object returned by get() or passed to load() controls all of this. It intercepts every fetch() and connect() call the dynamic Worker makes.

Block all outbound access

Set globalOutbound to null to fully isolate the dynamic Worker from the network:

return {
	mainModule: "index.js",
	modules: { "index.js": code },
	globalOutbound: null,
};

Any fetch() or connect() call from the dynamic Worker throws an exception.

In this mode, you can still give the Dynamic Worker direct access to specific resources and services using bindings. This is the most secure way to design your sandbox: block the Internet, then provide specific capabilities through bindings.

If you need compatibility with existing HTTP client libraries running inside your Dynamic Worker sandbox, blocking fetch() may not be feasible. In that case, intercept requests instead.

Intercept outbound requests

To intercept outbound requests, define a WorkerEntrypoint class in the loader Worker that acts as a gateway. Every fetch() and connect() call the dynamic Worker makes goes through this gateway instead of hitting the network directly. Pass the gateway to the dynamic Worker with globalOutbound and ctx.exports:

import { WorkerEntrypoint } from "cloudflare:workers";

export class HttpGateway extends WorkerEntrypoint {
	async fetch(request) {
		// Every outbound fetch() from the dynamic Worker arrives here.
		// Inspect, modify, block, or forward the request.
		return fetch(request);
	}
}

export default {
	async fetch(request, env, ctx) {
		const worker = env.LOADER.get("my-worker", async () => {
			return {
				compatibilityDate: "$today",
				mainModule: "index.js",
				modules: { "index.js": code },

				// Pass the gateway as a service binding.
				// The dynamic Worker's fetch() and connect() calls
				// are routed through HttpGateway instead of going
				// to the network directly.
				globalOutbound: ctx.exports.HttpGateway,
			};
		});

		return worker.getEntrypoint().fetch(request);
	},
};

From here, you can add any logic to the gateway, such as restricting destinations, injecting credentials, or logging requests.

Inject credentials

A common pattern is attaching credentials to outbound requests so the dynamic Worker never sees the secret. Similar to custom bindings, you can use ctx.props to pass per-tenant or per-request context to the gateway.

The dynamic Worker calls fetch() normally. HttpGateway intercepts the request, attaches the token from the loader Worker's environment, and forwards it. The dynamic Worker never has access to API_TOKEN.

import { WorkerEntrypoint } from "cloudflare:workers";

export class HttpGateway extends WorkerEntrypoint {
	async fetch(request) {
		let url = new URL(request.url);
		const headers = new Headers(request.headers);

		// For requests to api.example.com, inject credentials.
		if (url.hostname === "api.example.com") {
			headers.set("Authorization", `Bearer ${this.env.API_TOKEN}`);
			headers.set("X-Tenant-Id", this.ctx.props.tenantId);
		}

		return fetch(request, { headers });
	}
}

export default {
	async fetch(request, env, ctx) {
		const tenantId = getTenantFromRequest(request);

		const worker = env.LOADER.get(`tenant:${tenantId}`, async () => {
			return {
				mainModule: "index.js",
				modules: {
					"index.js": `
						export default {
							async fetch() {
								const resp = await fetch("https://api.example.com/data");
								return new Response(await resp.text());
							},
						};
					`,
				},
				globalOutbound: ctx.exports.HttpGateway({
					props: { tenantId },
				}),
			};
		});

		return worker.getEntrypoint().fetch(request);
	},
};

Usage

import { DirectoryListing } from "~/components";

This section covers the main runtime controls for Dynamic Workers.

Custom resource limits

By default, each Dynamic Worker invocation uses your Workers plan limits for CPU time and subrequests. Custom limits let you programmatically enforce lower limits on the Dynamic Worker's resource usage.

You can set limits for the maximum CPU time and number of subrequests per invocation. If a Dynamic Worker reaches either limit, it immediately throws an exception.

Set custom limits

Specify custom limits as part of the Worker code:

const worker = env.LOADER.get("my-worker", async () => {
  return {
    compatibilityDate: "$today",
    mainModule: "index.js",
    modules: { "index.js": code },
    limits: { cpuMs: 10, subRequests: 5 },
  };
});

You can also specify limits as part of the getEntrypoint() call:

// Get the Worker's default entrypoint with custom limits.
// If limits were already specified as part of the Worker code,
// the lower of the two limits is used.
const entrypoint = worker.getEntrypoint(null, { limits: { cpuMs: 10, subRequests: 5 } });
await entrypoint.fetch(...);

Observability

import { WranglerConfig } from "~/components";

Dynamic Workers capture logs from console.log() calls, exceptions, and request metadata during execution. To access these logs, attach a Tail Worker, a callback that runs after the Dynamic Worker finishes and passes along all the logs, exceptions, and metadata it collected.

This guide will show you how to:

Capture logs with Tail Workers

To save logs emitted by a Dynamic Worker, capture them and write them to a storage destination. Setting this up requires three steps:

  1. Enabling Workers Logs on the loader Worker so that log output is saved.
  2. Defining a Tail Worker that receives logs from the Dynamic Worker and writes them to Workers Logs.
  3. Attaching the Tail Worker to the Dynamic Worker when you create it.

:::note Tail Workers run asynchronously after the Dynamic Worker has already sent its response, so they do not add latency to the request. :::

Enable Workers Logs on the loader Worker

Turn on Workers Logs by adding the observability setting to the loader Worker's Wrangler configuration. Workers Logs only captures log output from the loader Worker itself. Dynamic Workers are separate, so their console.log() calls are not included automatically. To get Dynamic Worker logs into Workers Logs, define a Tail Worker that receives logs from the Dynamic Worker and writes them into the loader Worker's Workers Logs.

[observability]
enabled = true
head_sampling_rate = 1

Define the Tail Worker

When a Dynamic Worker runs, the runtime collects all of its console.log() calls, exceptions, and request metadata. By default, those logs are discarded after the Dynamic Worker finishes.

To keep them, define a Tail Worker on the loader Worker. A Tail Worker is a class with a tail() method that determines what happens with the logs. The runtime calls this method after the Dynamic Worker finishes, passing in everything it collected during execution.

Inside tail(), write each log entry to Workers Logs by calling console.log() with a JSON object. Include a workerId field in each entry so you can identify which Dynamic Worker produced each log and filter the logs later.

import { WorkerEntrypoint } from "cloudflare:workers";

export class DynamicWorkerTail extends WorkerEntrypoint {
	async tail(events) {
		for (const event of events) {
			for (const log of event.logs) {
				console.log({
					source: "dynamic-worker-tail",
					workerId: this.ctx.props.workerId,
					level: log.level,
					message: log.message,
				});
			}
		}
	}
}

The Tail Worker reads workerId from this.ctx.props.workerId. You set this value when you attach the Tail Worker to the Dynamic Worker in the next step.

Since the Tail Worker is defined within the loader Worker, its console.log() output is saved to Workers Logs along with the loader Worker's own logs.

Attach the Tail Worker to the Dynamic Worker

When you create the Dynamic Worker, pass the Tail Worker in the tails array. This tells the runtime to send the Dynamic Worker's collected logs to the Tail Worker after execution completes.

To reference the DynamicWorkerTail class you defined in the previous step, use ctx.exports. ctx is the third parameter in the loader Worker's fetch(request, env, ctx) handler. ctx.exports gives you access to classes exported from the loader Worker. Because the Dynamic Worker runs in a separate context and cannot access the class directly, use ctx.exports.DynamicWorkerTail() to create a reference that the runtime can wire up to the Dynamic Worker.

You also need to tell the Tail Worker which Dynamic Worker it is logging for. Since the Tail Worker runs separately from the loader Worker's fetch() handler, it does not have access to your local variables. Use the props option when you create the instance to pass information. props is a plain object of key-value pairs that you set when attaching the Tail Worker and that the Tail Worker can read at this.ctx.props when it runs. In this case, pass the workerId so the Tail Worker knows which Dynamic Worker produced the logs.

const worker = env.LOADER.get(workerId, () => ({
	mainModule: WORKER_MAIN,
	modules: {
		[WORKER_MAIN]: WORKER_SOURCE,
	},
	tails: [
		ctx.exports.DynamicWorkerTail({
			props: { workerId },
		}),
	],
}));

return worker.getEntrypoint().fetch(request);

Return logs in real time

The setup in Capture logs with Tail Workers stores logs for later retrieval, but sometimes you need logs immediately during development. The challenge is that the Tail Worker and the loader Worker's fetch() handler run separately. The Tail Worker has the logs, but the fetch() handler builds the response. You need a shared location where the Tail Worker can write the logs and the fetch() handler can read them.

A Durable Object works well for this. Both the Tail Worker and the fetch() handler can look up the same Durable Object instance by name. The Tail Worker writes logs into it after the Dynamic Worker finishes, and the fetch() handler reads them out and includes them in the response.

The pattern works like this:

  1. The fetch() handler creates a log session in a Durable Object before running the Dynamic Worker.
  2. The Dynamic Worker runs and produces logs.
  3. After the Dynamic Worker finishes, the Tail Worker writes the collected logs to the same Durable Object.
  4. The fetch() handler reads the logs from the Durable Object and returns them in the response.
import { exports } from "cloudflare:workers";

// 1. Create a log session before running the Dynamic Worker.
const logSession = exports.LogSession.getByName(workerName);
const logWaiter = await logSession.waitForLogs();

// 2. Run the Dynamic Worker.
const response = await worker.getEntrypoint().fetch(request);

// 3. Wait up to 1 second for the Tail Worker to deliver logs.
const logs = await logWaiter.getLogs(1000);

For a full working implementation, refer to the Dynamic Workers Playground example.

Static assets

import { TypeScriptExample, WranglerConfig } from "~/components";

Dynamic Workers can serve static assets like HTML pages, JavaScript bundles, images, and other files alongside your Worker code. This is useful when you need a Dynamic Worker to serve a full-stack application.

Static assets for Dynamic Workers work differently from static assets in regular Workers. Instead of uploading assets at deploy time, you provide them at runtime through the Worker Loader get() callback, sourcing them from R2, KV, or another storage backend.

How it works

There are three parts to setting up static assets for Dynamic Workers:

  1. Store the assets — Upload static files to a KV namespace, keyed by project ID and pathname.
  2. Define an asset binding in the loader Worker — Create a class that handles requests for static files by reading them from KV and returning them with the correct headers.
  3. Pass the binding to the Dynamic Worker — The Dynamic Worker uses it to serve static files by calling env.ASSETS.fetch(request).

Store the static assets

Static assets are stored in a KV namespace, separated by project ID so each project's files are isolated from each other:

project/{projectId}/assets/index.html      →  file content
project/{projectId}/assets/app.js          →  file content
project/{projectId}/manifest               →  asset manifest

When a user deploys their project through your platform's upload API, store each file in KV under its pathname:

await env.KV_ASSETS.put(`project/${projectId}/assets${pathname}`, fileContent);

You also need to store a manifest, a mapping that tells the asset handler which files exist and what their content types are. Use buildAssetManifest() from @cloudflare/worker-bundler to generate it from your assets:

import { buildAssetManifest } from "@cloudflare/worker-bundler";

const assets = {
	"/index.html": htmlContent,
	"/app.js": jsContent,
	"/style.css": cssContent,
};

const manifest = await buildAssetManifest(assets);

await env.KV_ASSETS.put(
	`project/${projectId}/manifest`,
	JSON.stringify(manifest),
);

:::note

The examples on this page use KV for asset storage, but you can also use R2, which is recommended for larger files like images or videos. A common pattern is to store assets in R2 and use KV as a cache layer for frequently accessed files.

:::

Add bindings to the loader Worker

Grant the loader Worker access to the KV namespace where you stored the assets:

{
	"worker_loaders": [{ "binding": "LOADER" }],
	"kv_namespaces": [
		{
			"binding": "KV_ASSETS",
			"id": "<your-kv-namespace-id>",
		},
	],
}

Define the asset binding

Create a class in the loader Worker that extends WorkerEntrypoint and define a fetch() method. WorkerEntrypoint makes this method callable from the Dynamic Worker using RPC. When the Dynamic Worker calls env.ASSETS.fetch(request), it runs this method in the loader Worker, where the KV binding and your asset-serving logic live.

The class takes a projectId prop so it knows which project's assets to look up. When fetch() is called, it:

  1. Loads the project's asset manifest from KV.
  2. Resolves the request pathname to a file.
  3. Fetches the file content from KV.
  4. Returns a Response with the correct Content-Type header.

Use @cloudflare/worker-bundler to handle static asset serving

Instead of writing your own logic to match request paths to files, detect content types, and set cache headers, use the @cloudflare/worker-bundler package to handle static asset serving. In your fetch() method, pass handleAssetRequest() two things:

handleAssetRequest() serves the file if it finds a match in the manifest, with the correct headers for content type and caching.

import { WorkerEntrypoint } from "cloudflare:workers";
import { handleAssetRequest } from "@cloudflare/worker-bundler";

export class AssetBinding extends WorkerEntrypoint {
	async fetch(request: Request) {
		const { projectId } = this.ctx.props;

		// Load the project's asset manifest from KV
		const manifest = await this.env.KV_ASSETS.get(
			`project/${projectId}/manifest`,
			{ type: "json", cacheTtl: 300 },
		);

		if (!manifest) {
			return new Response("No assets found", { status: 404 });
		}

		// Storage object — handleAssetRequest calls get() to
		// read file content when it needs to serve an asset
		const storage = {
			async get(pathname: string) {
				return this.env.KV_ASSETS.get(
					`project/${projectId}/assets${pathname}`,
					{ type: "arrayBuffer", cacheTtl: 86_400 },
				);
			},
		};

		const response = await handleAssetRequest(request, manifest, storage);
		return response ?? new Response("Not Found", { status: 404 });
	}
}

:::note

The cacheTtl option caches KV results so repeated requests do not hit KV storage every time. The manifest uses a shorter cache (5 minutes) so new deploys are picked up quickly. Asset content uses a longer cache (24 hours) since files at the same path do not change between deploys.

:::

Once AssetBinding is exported, it becomes available on ctx.exports in the loader Worker's fetch() handler. ctx is the handler's third parameter, after request and env. This is how you pass it to the Dynamic Worker in the next step.

Pass the asset binding to the Dynamic Worker

When you call get() to create the Dynamic Worker, include the AssetBinding in the env object so the Dynamic Worker can use it to serve static files. To reference the AssetBinding class you defined in the previous step, use ctx.exports.AssetBinding() and pass the projectId as a prop so it knows which project's assets to serve. This works the same way as custom bindings — props is how you pass information to the class, and the class reads it at this.ctx.props when it runs.

export default {
	async fetch(request: Request, env: Env, ctx: ExecutionContext) {
		const projectId = getProjectIdFromRequest(request);

		const worker = env.LOADER.get(projectId, async () => {
			const serverCode = await loadServerCode(projectId);

			return {
				mainModule: "index.js",
				modules: {
					"index.js": { js: serverCode },
				},
				compatibilityDate: "$today",
				env: {
					ASSETS: ctx.exports.AssetBinding({
						props: { projectId },
					}),
				},
			};
		});

		return await worker.getEntrypoint().fetch(request);
	},
};

The Dynamic Worker sees ASSETS as a binding and can call env.ASSETS.fetch(request) because that is the method you defined on AssetBinding. When the Dynamic Worker calls that method, it runs in the loader Worker, where your AssetBinding class reads the manifest and file content from KV.

Use the asset binding in the Dynamic Worker

From the Dynamic Worker's perspective, env.ASSETS works like any other binding. The user writes their server code and calls env.ASSETS.fetch() to serve static files:

// Inside the Dynamic Worker
export default {
	async fetch(request: Request, env: Env) {
		const url = new URL(request.url);

		// Handle API routes directly
		if (url.pathname.startsWith("/api/")) {
			return Response.json({ hello: "world" });
		}

		// Everything else — serve static assets
		return env.ASSETS.fetch(request);
	},
};

When the Dynamic Worker calls env.ASSETS.fetch(request), the call goes through RPC to the loader Worker's AssetBinding, which looks up the file in the manifest and reads it from KV. The Dynamic Worker does not need to handle any of this — it calls env.ASSETS.fetch(request) and gets back the file with the correct headers, ready to return to the client.