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 });
},
};

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",
}

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",
}

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,
},
},
}

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);
}),
};