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 fetchhandler, 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.
Async Workers
Section titled “Async Workers”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
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
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 }); },};Python Workers
Section titled “Python Workers”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
const kv = yield* Cloudflare.KV.Namespace("Cache");
export const Worker = Cloudflare.Worker("Worker", { main: "./src/worker.py", env: { CACHE: kv },});Writing the Python handler
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"]Effect Workers
Section titled “Effect Workers”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"); }), }; }),) {}Worker Layer
Section titled “Worker Layer”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"); }), }; }),) {}Configuration
Section titled “Configuration”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",}Bundling & Tree-shaking
Section titled “Bundling & Tree-shaking”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 },}URLs & Domains
Section titled “URLs & Domains”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 },});Versions & Gradual Deployments
Section titled “Versions & Gradual Deployments”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 }, },});The Worker’s own URL
Section titled “The Worker’s own URL”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 }Observability
Section titled “Observability”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, }, },}Tail Workers
Section titled “Tail Workers”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
Section titled “Workers Cache”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, },}Background Work & Scopes
Section titled “Background Work & Scopes”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
// initconst 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 }); }),};R2 Bucket
Section titled “R2 Bucket”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.
// initconst 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 }); }),};KV Namespace
Section titled “KV Namespace”Bind a KV namespace with Cloudflare.KV.ReadWriteNamespace. KV provides
eventually-consistent, low-latency key-value reads replicated
globally across Cloudflare’s edge.
// initconst kv = yield* Cloudflare.KV.ReadWriteNamespace(MyKV);
return { fetch: Effect.gen(function* () { const value = yield* kv.get("my-key"); return HttpServerResponse.text(value ?? "not found"); }),};D1 Database
Section titled “D1 Database”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.
// initconst 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); }),};Durable Objects
Section titled “Durable Objects”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.
// initconst 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
Section titled “Containers”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 }), ), ),) {}Dynamic Workers
Section titled “Dynamic Workers”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.
// initconst 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); }),};