Skip to content

Worker

Source: src/Cloudflare/Workers/Worker.ts

A Cloudflare Worker host with deploy-time binding support and runtime export collection.

A Worker follows a two-phase pattern. The outer Effect.gen runs at deploy time to bind resources (KV, R2, Durable Objects, etc.). It returns an object whose properties are the Worker’s runtime handlers — fetch for HTTP requests and any additional RPC methods.

Effect.gen(function* () {
// Phase 1: bind resources (runs at deploy time)
const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return {
// Phase 2: runtime handlers (runs on each request)
fetch: Effect.gen(function* () {
const value = yield* kv.get("key");
return HttpServerResponse.text(value ?? "not found");
}),
};
})

There are three ways to define a Worker, from simplest to most flexible. See the Functions & Servers page for the full explanation.

  • Async — plain async fetch handler, no Effect runtime in the bundle.
  • Effect — Effect implementation passed directly, single file.
  • Layer — class and .make() in a single file; Rolldown tree-shakes .make() from consumers.

You don’t have to use Effect for your runtime code. If you create a Worker resource with main pointing at a file but provide no Effect.gen implementation, Alchemy bundles and deploys that file as-is. Your handler is a plain async fetch — no Effect runtime is included in the bundle.

Use the env prop to declare which resources, Config values, and literal env vars are available at runtime, and Cloudflare.InferEnv to extract a fully typed env object from them.

See the Workers guide for a comprehensive walkthrough of all binding types (R2, D1, Durable Objects, Assets, and more).

Defining an async Worker in your stack

alchemy.run.ts
const db = yield* Cloudflare.D1.Database("DB");
const bucket = yield* Cloudflare.R2.Bucket("Bucket");
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: { db, bucket },
});

Writing the async handler

src/worker.ts
import type { WorkerEnv } from "../alchemy.run.ts";
export default {
async fetch(request: Request, env: WorkerEnv) {
if (request.method === "GET") {
const object = await env.bucket.get("key");
return new Response(object?.body ?? null);
}
return new Response("Not Found", { status: 404 });
},
};

Point main at a .py file to deploy a Python Worker (open beta). There is no bundling step — the entry and every sibling .py module upload as-is and are interpreted by Pyodide, and the python_workers compatibility flag is added automatically. Like async Workers, Python Workers take no inline Effect implementation; declare bindings with the env prop and read them from self.env in Python.

Dependencies come from pyproject.toml next to the entry: Alchemy vendors [project.dependencies] with uv against the Pyodide wheel index and uploads them under python_modules/. If a python_modules/ directory already exists (e.g. produced by pywrangler sync), it is uploaded as-is and uv is not invoked.

See the Python Workers guide for the full walkthrough.

Defining a Python Worker in your stack

alchemy.run.ts
const kv = yield* Cloudflare.KV.Namespace("Cache");
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.py",
env: { CACHE: kv },
});

Writing the Python handler

src/worker.py
from workers import Response, WorkerEntrypoint
class Default(WorkerEntrypoint):
async def fetch(self, request):
cached = await self.env.CACHE.get("greeting")
return Response(cached or "Hello from Python!")

Vendoring dependencies with pyproject.toml

# src/pyproject.toml — vendored with uv on deploy
[project]
name = "my-worker"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = ["humanize"]

Pass the Effect implementation as the third argument. This is the simplest Effect-based approach — everything lives in one file. Convenient for standalone Workers that don’t need to be referenced by other Workers.

export default class MyWorker extends Cloudflare.Worker<MyWorker>()(
"MyWorker",
{ main: import.meta.url },
Effect.gen(function* () {
// init: bind resources
const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return {
// runtime: use them
fetch: Effect.gen(function* () {
const value = yield* kv.get("key");
return HttpServerResponse.text(value ?? "not found");
}),
};
}),
) {}

When two Workers need to reference each other (e.g. WorkerA calls WorkerB and vice versa), or you simply want optimal tree-shaking, define the Worker class separately from its .make() call. The class is a lightweight identifier; .make() provides the runtime implementation as an export default. Rolldown treats .make() as pure, so any Worker that imports the class to bind it will not pull in the .make() dependencies — the bundler tree-shakes them away entirely.

The class and .make() can live in the same file. This is the same pattern used by Container and DurableObject, and is recommended for any cross-Worker or cross-DO bindings.

Worker Layer (class + .make() in one file)

// src/WorkerB.ts — the tag carries the name + RPC shape; props live
// on `.make()`.
export class WorkerB extends Cloudflare.Worker<
WorkerB,
{ greet: (name: string) => Effect.Effect<string> }
>()("WorkerB") {}
export default WorkerB.make(
{ main: import.meta.url },
Effect.gen(function* () {
// init: bind resources
const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return {
// runtime: use them
greet: (name: string) =>
Effect.gen(function* () {
yield* kv.put("last-greeted", name);
return `Hello ${name}`;
}),
};
}),
);

Binding a Worker Layer from another Worker

// src/WorkerA.ts — imports WorkerB; bundler tree-shakes .make()
import WorkerB from "./WorkerB.ts";
export default class WorkerA extends Cloudflare.Worker<WorkerA>()(
"WorkerA",
{ main: import.meta.url },
Effect.gen(function* () {
const b = yield* Cloudflare.Workers.bindWorker(WorkerB);
return {
fetch: Effect.gen(function* () {
return yield* b.greet("world");
}),
};
}),
) {}

The props object controls compatibility flags, static assets, and build options. These are evaluated at deploy time.

Enabling Node.js compatibility

{
main: import.meta.url,
compatibility: {
flags: ["nodejs_compat"],
date: "2026-03-17",
},
}

Serving static assets

{
main: import.meta.url,
assets: "./public",
}

Assets-only Worker (static site)

Omit main and script entirely to deploy a static site: no Worker code is uploaded — Cloudflare’s asset layer serves every request and applies htmlHandling / notFoundHandling (including SPA fallback) itself, exactly like an assets-only wrangler deploy.

const site = yield* Cloudflare.Worker("Site", {
assets: {
directory: "./public",
htmlHandling: "drop-trailing-slash",
notFoundHandling: "404-page",
},
domain: "static.example.com",
});

Zone routes

{
main: import.meta.filename,
routes: [
{ pattern: "api.example.com/*", zoneName: "example.com" },
{ pattern: "example.com/api/*", zoneId: "<YOUR_ZONE_ID>" },
],
}

Deploying a prebuilt Worker without bundling

When main already points at a complete, runtime-ready ESM bundle produced by an external tool (e.g. OpenNext), set bundle: false to upload it byte-for-byte. The entry’s directory is walked recursively and every file matching the module rules (by default .js, .mjs, .wasm, .txt, .html, .sql, and .bin) is uploaded as an additional module named by its path relative to that directory.

{
main: "./.open-next/worker.js",
bundle: false,
assets: "./.open-next/assets",
}

main is bundled with rolldown at deploy time. Top-level calls in the effect, @effect/*, alchemy, @alchemy.run/*, and @distilled.cloud/* packages receive #__PURE__ annotations by default, so anything the Worker doesn’t use from those packages is tree-shaken out of the bundle. Any other package — including your own app — is left untouched unless you list it explicitly.

Treat additional packages as pure

Pass package names (or picomatch globs) via build.pure.packages to annotate them in addition to the defaults.

{
main: "./src/worker.ts",
build: {
pure: { packages: ["my-lib", "@my-scope/*"] },
},
}

Listing a package annotates calls whose result is bound (variable initializers, exports) — safe anywhere. If a listed package also declares "sideEffects": false (or []) in its package.json, that combination opts it into full annotation: top-level calls whose result is discarded (e.g. router.on("/path", handler) registrations) are also marked pure and deleted under minification when unused. Only list a sideEffects: false package if its modules really are free of meaningful top-level side effects. The effect, alchemy, and @distilled.cloud defaults declare exactly that, on purpose — their modules are designed to be fully tree-shakeable.

Disable pure annotations

{
main: "./src/worker.ts",
build: { pure: false },
}

Every URL that serves the Worker is collected in worker.urls, most significant first, and worker.url is always urls[0]. The ranking: the canonical custom domain (domain.name), then aliases in declared order, then the stable workers.dev URL, then version preview URLs. Under alchemy dev, urls is the local dev server’s [localhost, ...LAN] addresses instead. Redirect hostnames never appear in urls — they serve no content.

The workersDev prop controls the workers.dev surface (true by default = stable URL + version previews; false = neither; object form toggles independently), and the domain prop attaches custom domains — DNS records and edge certificates are managed automatically.

Custom domain with aliases and redirects

const worker = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
domain: {
name: "example.com",
aliases: ["www.example.com"],
redirects: ["old.example.com"], // 301 → https://example.com
},
});
// worker.url === "https://example.com"
// worker.urls === ["https://example.com", "https://www.example.com",
// "https://<name>.<account>.workers.dev"]

workers.dev toggles

// No workers.dev URLs at all:
{ main: "./src/api.ts", workersDev: false, domain: "api.example.com" }
// Previews only — each deploy's version preview URL becomes worker.url:
{ main: "./src/api.ts", workersDev: { enabled: false, previewsEnabled: true } }

All URLs as a CORS allow-list

const site = yield* Cloudflare.Worker("Site", {
main: "./src/site.ts",
domain: { name: "example.com", aliases: ["www.example.com"] },
});
const api = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
env: { ALLOWED_ORIGINS: site.urls },
});

The version prop maps Cloudflare’s versions and gradual deployments onto Alchemy stages. A Worker with version.parent set uploads an immutable version to the parent Worker’s script instead of creating its own — by default with no traffic, reachable only at its preview URL (worker.url), which is the PR-preview workflow. Give it traffic to run it as a canary, or use version.traffic on a normal Worker to roll out its own deploys gradually.

A version worker’s url is its aliased preview URL (<alias>-<name>.<subdomain>.workers.dev) — the alias is derived from the stack, stage, and logical id (override with version.alias), so the URL is stable across deploys and always points at the latest uploaded version. The per-version URL (<version-prefix>-...) is also returned in domains. Because the aliased URL is known before the version exists, Worker.URL works on version workers and resolves to it.

A version carries code, bindings, and compatibility settings. Script-level settings (routes, domains, crons, tags, observability, …) belong to the parent and are rejected on version workers, as are locally-hosted Durable Object or Workflow classes. Preview URLs require the parent’s workers.dev subdomain to be enabled (the default).

PR preview: a version of another stage’s Worker

// The staging stage deploys the real Worker; a PR stage uploads its
// code as a zero-traffic version of staging's script and gets back a
// stable preview URL.
const parent = yield* Cloudflare.Worker.ref("MyWorker", {
stage: "staging",
});
const preview = yield* Cloudflare.Worker("MyWorker", {
main: "./src/worker.ts",
version: { parent, message: `PR #${process.env.PR_NUMBER}` },
});
// preview.url -> https://<alias>-<name>.<subdomain>.workers.dev
// (stable across deploys; re-points at each newly uploaded version)

Canary: send 10% of the parent’s traffic to a version

const parent = yield* Cloudflare.Worker.ref("MyWorker", { stage: "prod" });
yield* Cloudflare.Worker("MyWorker", {
main: "./src/worker.ts",
version: { parent, traffic: 10 },
});

Gradual rollout of a Worker’s own deploy

// The new version takes 25% of traffic; the previously-live version
// keeps 75%. Bump traffic (or remove the prop) and re-deploy to promote.
yield* Cloudflare.Worker("MyWorker", {
main: "./src/worker.ts",
version: { traffic: 25 },
});

Keep users on one version during the rollout

// Percentages route each request independently; affinity pins users by
// filling the Cloudflare-Workers-Version-Key header on zone traffic —
// here from the session cookie, falling back to the client IP. Requires
// a `domain` or `routes` (with `parent`, the parent's).
yield* Cloudflare.Worker("MyWorker", {
main: "./src/worker.ts",
domain: "api.example.com",
version: {
traffic: 25,
affinity: { cookie: "session_id", ip: true },
},
});

Worker.URL injects the URL a Worker is served at as a binding on that same Worker — the first custom domain if one is configured, otherwise its workers.dev URL, always equal to the resource’s url attribute. Under alchemy dev it resolves to the local dev server’s URL.

Read the Worker’s own URL inside a handler

Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
// Attaches the binding and returns a deferred accessor.
const url = yield* Cloudflare.Worker.URL;
return {
fetch: Effect.gen(function* () {
const publicUrl = yield* url;
return Response.json({ url: publicUrl });
}),
};
}).pipe(Effect.provide(Cloudflare.Workers.URLBinding)),
);

Inject the URL into an async Worker’s env

InferEnv types the entry as string. A VITE_-prefixed key on a vite-built Worker is additionally inlined into the client bundle as import.meta.env.VITE_PUBLIC_URL at build time.

export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: { PUBLIC_URL: Cloudflare.Worker.URL },
});
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
// { PUBLIC_URL: string }

Cloudflare Workers Observability is on by default — logs.enabled and logs.invocationLogs are turned on if you don’t pass an observability prop. Pass the prop yourself to tune sampling, enable persistence, or turn on the new traces channel (the same toggle the dashboard’s Observability tab writes).

Field names match the Cloudflare API (camelCased): headSamplingRate, invocationLogs, etc.

{
main: import.meta.url,
observability: {
enabled: true,
headSamplingRate: 1,
logs: {
enabled: true,
invocationLogs: true,
headSamplingRate: 1,
persist: true,
},
traces: {
enabled: true,
headSamplingRate: 1,
persist: true,
},
},
}

A Tail Worker receives execution traces (console logs, exceptions, event metadata) from other Workers. List it in a producer’s tailConsumers and export a tail() handler from the consumer; Cloudflare delivers each invocation’s trace events to every listed consumer after the invocation completes.

Sending a Worker’s traces to a Tail Worker

const tailWorker = yield* Cloudflare.Worker("TailWorker", {
// exports: export default { async tail(events, env, ctx) { ... } }
main: "./src/tail.ts",
});
const api = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
tailConsumers: [tailWorker],
});

A streaming Tail Worker receives the same invocation’s events live, while the producer is still executing: list it in streamingTailConsumers and export a tailStream() handler that is invoked with the invocation’s onset event and returns a handler for every subsequent event of the session, ending with the terminal outcome.

Streaming a Worker’s events to a streaming Tail Worker

const streamTailWorker = yield* Cloudflare.Worker("StreamTailWorker", {
// exports: export default {
// tailStream(onset, env, ctx) {
// return (event) => { ... }; // log, spanOpen, ..., outcome
// },
// }
main: "./src/stream-tail.ts",
});
const api = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
streamingTailConsumers: [streamTailWorker],
});

Workers Cache puts a regionally tiered cache in front of the Worker — cache hits are served from the edge without invoking the Worker (and without billing CPU time). In an Effect-native Worker, enable it by yielding Cloudflare.cache() in the init phase, which also returns the runtime purge client; async Workers use the cache prop instead. Control what gets cached from your handlers via standard response headers: Cache-Control (including stale-while-revalidate), Cache-Tag for tag-based purging, and Vary for content negotiation.

The cache is scoped to a single Worker version by default, so every deploy starts cold. Set crossVersionCache: true to share cached responses across versions.

Enabling and purging the cache in an Effect Worker

Effect.gen(function* () {
// init: enable Workers Cache on this Worker
const { purge } = yield* Cloudflare.cache({ crossVersionCache: true });
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
if (request.url.startsWith("/invalidate")) {
yield* purge({ tags: ["products"] });
return HttpServerResponse.text("purged");
}
return HttpServerResponse.text("hello", {
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
"Cache-Tag": "products,product:123",
},
});
}),
};
})

Enabling Workers Cache on an async Worker

{
main: "./src/worker.ts",
cache: {
enabled: true,
crossVersionCache: true,
},
}

Each incoming event (fetch, RPC call, scheduled run) gets its own Effect Scope. When the handler finishes, the bridge closes that scope and registers the close promise with workerd’s ctx.waitUntil — so finalizers added with Effect.addFinalizer inside a handler run after the response is sent, without blocking it, and the Worker stays alive until they settle. Streaming responses transfer the scope to the stream, so those finalizers run when the stream completes instead.

For ad-hoc background work, WorkerExecutionContext.waitUntil(effect) forks an Effect with the caller’s full context and keeps the invocation alive until it settles. The context can be yielded once in the init closure and used from any handler; its methods are RuntimeContext- colored, so they can only run inside a handler.

The init closure is evaluated once per isolate: the bridge builds the Worker’s layer stack on the first event and every later event reuses the built services. Resolve services, bind resources, build handlers there — one-shot I/O that caches a plain value (e.g. fetching a secret for a client) is fine, but nothing disposable: the build scope is never closed (workerd has no isolate-teardown hook), so a finalizer added in the init closure never runs, and I/O-backed objects (sockets, response bodies) are pinned to the request that created them. Anything that needs cleanup belongs in a handler, where Effect.addFinalizer attaches to the per-event scope.

Post-response cleanup with a scope finalizer

return {
fetch: Effect.gen(function* () {
// runs after this response is sent, kept alive by waitUntil
yield* Effect.addFinalizer(() => flushMetrics().pipe(Effect.ignore));
return HttpServerResponse.text("ok");
}),
};

Background work with waitUntil

// init
const exec = yield* Cloudflare.WorkerExecutionContext;
return {
fetch: Effect.gen(function* () {
// respond now; the audit write completes in the background
yield* exec.waitUntil(writeAuditLog(event));
return HttpServerResponse.text("accepted", { status: 202 });
}),
};

Bind an R2 bucket in the init phase with Cloudflare.R2.ReadWriteBucket. The returned handle exposes get, put, delete, and list methods you can call in your runtime handlers.

// init
const bucket = yield* Cloudflare.R2.ReadWriteBucket(MyBucket);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const key = request.url.split("/").pop()!;
if (request.method === "GET") {
const object = yield* bucket.get(key);
return object
? HttpServerResponse.text(yield* object.text())
: HttpServerResponse.empty({ status: 404 });
}
yield* bucket.put(key, request.stream);
return HttpServerResponse.empty({ status: 201 });
}),
};

Bind a KV namespace with Cloudflare.KV.ReadWriteNamespace. KV provides eventually-consistent, low-latency key-value reads replicated globally across Cloudflare’s edge.

// init
const kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return {
fetch: Effect.gen(function* () {
const value = yield* kv.get("my-key");
return HttpServerResponse.text(value ?? "not found");
}),
};

Bind a D1 database with Cloudflare.D1.QueryDatabase. D1 is a serverless SQLite database — use prepare to build parameterized queries and all, first, or run to execute them.

// init
const db = yield* Cloudflare.D1.QueryDatabase(MyDatabase);
return {
fetch: Effect.gen(function* () {
const results = yield* db
.prepare("SELECT * FROM users WHERE id = ?")
.bind(userId)
.all();
return yield* HttpServerResponse.json(results);
}),
};

Yield a DurableObject class in the init phase to get a namespace handle. Call getByName or getById to get a typed RPC stub, then call its methods from your runtime handlers.

// init
const counters = yield* Counter;
return {
fetch: Effect.gen(function* () {
const counter = counters.getByName("user-123");
const value = yield* counter.increment();
return HttpServerResponse.text(String(value));
}),
};

Containers run long-lived processes alongside Durable Objects. Provide Cloudflare.Containers.layer(Sandbox, …) on a DO’s init to bind, start, and monitor the container; then yield* Sandbox resolves the running instance. Call its typed methods or use getTcpPort to make HTTP requests to its exposed ports.

export default class Agent extends Cloudflare.DurableObject<Agent>()(
"Agents",
Effect.gen(function* () {
const sandbox = yield* Sandbox;
return Effect.gen(function* () {
return {
exec: (cmd: string) => sandbox.exec(cmd),
health: () =>
Effect.gen(function* () {
const { fetch } = yield* sandbox.getTcpPort(3000);
const res = yield* fetch(
HttpClientRequest.get("http://container/health"),
);
return yield* res.text;
}),
};
});
}).pipe(
Effect.provide(
Cloudflare.Containers.layer(Sandbox, { enableInternet: true }),
),
),
) {}

WorkerLoader lets you spin up ephemeral Workers at runtime from inline JavaScript modules. This is useful for sandboxing user-provided code or running untrusted scripts in isolation.

// init
const loader = yield* Cloudflare.WorkerLoader("Loader");
return {
fetch: Effect.gen(function* () {
const worker = yield* loader.load({
compatibilityDate: "2026-01-28",
mainModule: "worker.js",
modules: {
"worker.js": `export default {
async fetch(req) { return new Response("sandboxed"); }
}`,
},
});
const res = yield* worker.fetch(
HttpClientRequest.get("https://worker/"),
);
return HttpServerResponse.fromClientResponse(res);
}),
};