Skip to content

Workers

In Alchemy, a Cloudflare Worker is a Runtime: a Resource that carries the code it runs. The name and props describe the Worker to deploy. The Effect is what it does:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
return {
fetch: Effect.gen(function* () {
return HttpServerResponse.text("Hello, world!");
}),
};
}),
);

main: import.meta.url tells Alchemy to bundle this file’s default export, the Worker itself, as the script. Yield the Worker from a Stack and expose its URL:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import Worker from "./src/worker.ts";
export default Alchemy.Stack(
"MyApp",
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
const worker = yield* Worker;
return { url: worker.url };
}),
);
Terminal window
bun alchemy deploy

Alchemy bundles the file, uploads it, enables the workers.dev subdomain, and prints the URL as a stack output.

Reach for a Worker whenever you need compute: HTTP APIs, frontends, queue consumers, cron jobs, RPC services. Every other building block, Durable Objects, D1, R2, Queues, is reached through a Worker by binding it.

Declare a Bucket next to the Worker and bind it. The Binding hands back a typed client, and the Layer you provide decides how the binding is implemented, here as a native r2_bucket binding on the Worker:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url },
Effect.gen(function* () {
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Uploads);
return {
fetch: Effect.gen(function* () {
const obj = yield* bucket.get("hello.txt");
return obj
? HttpServerResponse.text(yield* obj.text())
: HttpServerResponse.text("Not found", { status: 404 });
}).pipe(
Effect.catchTag("R2Error", (error) =>
Effect.succeed(HttpServerResponse.text(error.message, { status: 500 })),
),
),
};
}).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),
);

The binding is declared where it’s used, so deploying the Worker deploys the wiring. The client’s errors, like R2Error, live in the Effect type system, so you can’t forget to handle them. And the access level is in the name: ReadBucket, WriteBucket, or ReadWriteBucket, depending on what the Worker actually needs.

Every building block binds the same way. See KV, D1, Queues, Hyperdrive, and Durable Objects, or walk through it step by step in tutorial part 2.

Workers call each other’s methods directly. No HTTP routes, no schema, no public URL. Declare the callee as a class whose type carries its RPC shape, and attach its implementation with .make():

src/Greeter.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export class Greeter extends Cloudflare.Worker<
Greeter,
{ greet: (name: string) => Effect.Effect<string> }
>()("Greeter") {}
export default Greeter.make(
{ main: import.meta.url },
Effect.gen(function* () {
return {
greet: (name: string) => Effect.succeed(`Hello ${name}`),
};
}),
);

Bind it from another Worker with bindWorker and call greet through the typed stub:

src/Api.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Greeter } from "./Greeter.ts";
export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const greeter = yield* Cloudflare.Workers.bindWorker(Greeter);
return {
fetch: Effect.gen(function* () {
return HttpServerResponse.text(yield* greeter.greet("world"));
}),
};
}),
);

bindWorker(Greeter) registers a service binding on Api, so each call travels over Cloudflare’s in-account service-binding fabric and never the public internet. greeter.greet("world") is type-checked against Greeter’s declared shape end to end.

Importing the Greeter class pulls in no runtime code. Alchemy marks .make() as pure, so the bundler drops the implementation from every Worker that only binds it. For external clients across a trust boundary, where payloads need schema validation before they touch your code, reach for Effect RPC instead.

Pass assets and Cloudflare serves matching requests from its asset layer. Everything else invokes your handlers:

export default Cloudflare.Worker(
"Worker",
{ main: import.meta.url, assets: "./public" },
// ...
);

A path that matches no asset already falls through to the Worker. Set runWorkerFirst when a path the Worker must own could be answered by the asset layer instead, such as an asset file shadowing a route or a single-page-application fallback that would serve the app shell to an API call:

assets: {
directory: "./public",
// only these paths reach the Worker ahead of assets
runWorkerFirst: ["/api/*", "/admin/*"],
},

runWorkerFirst: true routes every request through the Worker, which then serves files itself via the ASSETS binding. The same routing applies under alchemy dev.

Omit main entirely for an assets-only Worker. No script is uploaded, and the asset layer applies htmlHandling and notFoundHandling itself. Static asset requests are free and never invoke a Worker:

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

A _headers or _redirects file in the directory is applied automatically, and a .assetsignore file excludes files from the upload. If the directory comes from a build command, use StaticSite. For Vite projects, use the Vite resource.

worker.urls is every URL that serves the Worker, most significant first, and worker.url is always urls[0]. Attach custom domains with domain:

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"]

Every hostname becomes a Cloudflare custom domain. DNS records and edge certificates are managed for you, and the zone must already exist in the account. A bare string is shorthand for { name }. Redirect hostnames answer with a permanent redirect before the Worker runs, so they never appear in urls.

Zone routes attach the Worker to a path pattern instead of a whole hostname:

routes: [
{ pattern: "example.com/api/*", zoneName: "example.com" },
],

workersDev controls the workers.dev surface:

workersDev: false, // no workers.dev URLs, url is the custom domain
workersDev: { enabled: false, previewsEnabled: true }, // preview URLs only

Under alchemy dev, urls is the dev server’s actual surface, the localhost address first and then the LAN addresses. Pass urls wholesale wherever a list of origins is needed:

const api = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
env: { ALLOWED_ORIGINS: site.urls }, // CORS allow-list
});

For zone setup, DNS records, and route patterns, see custom domains.

A Worker often needs the URL it is served at, to build absolute links or register a webhook. That URL only exists at deploy time, and a Worker can’t reference its own url Output, so Worker.URL injects it as a binding on the Worker itself. Yield it in the Construction phase, then yield the accessor it returns inside a handler:

export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const url = yield* Cloudflare.Worker.URL;
return {
fetch: Effect.gen(function* () {
const publicUrl = yield* url;
return yield* HttpServerResponse.json({ url: publicUrl });
}),
};
}),
);

The value is the first custom domain if one is configured, otherwise the workers.dev URL, and always equals the resource’s url attribute. Under alchemy dev it is the local dev server’s URL.

Async Workers declare it on env, where InferEnv types the entry as string. A VITE_-prefixed entry is also inlined into a Vite site’s client bundle, see the site’s own URL:

export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: { PUBLIC_URL: Cloudflare.Worker.URL },
});

A Worker doesn’t have to be an Effect program. Point main at a plain module, a classic async fetch handler or a prebuilt bundle from another tool, and declare bindings with the env prop. InferEnv derives the handler’s env type from them:

alchemy.run.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export const DB = Cloudflare.D1.Database("DB");
export const Worker = Cloudflare.Worker("Worker", {
main: "./src/worker.ts",
env: { Uploads, DB },
});
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
src/worker.ts
import type { WorkerEnv } from "../alchemy.run.ts";
export default {
async fetch(request: Request, env: WorkerEnv) {
const obj = await env.Uploads.get("hello.txt");
return obj
? new Response(await obj.text())
: new Response("Not found", { status: 404 });
},
};

InferEnv maps each entry to its native workers-types client. An R2 bucket becomes R2Bucket, a D1 database D1Database, a Durable Object or Container a typed DurableObjectNamespace, and Config or Redacted values string. The handler stays plain JavaScript, but the env can never drift from the infrastructure that produced it. Python Workers are async Workers whose main is a .py file.

Binding another Worker on env targets its default entrypoint. To expose a named RPC entrypoint, export a class extending Cloudflare’s native WorkerEntrypoint from the target Worker’s module:

src/target.ts
import { WorkerEntrypoint } from "cloudflare:workers";
export class Api extends WorkerEntrypoint {
async greet(name: string): Promise<string> {
return `hello ${name}`;
}
}
export default {
async fetch() {
return new Response("ok");
},
};

Import that Api class as a type in your infrastructure code. Cloudflare.WorkerEntrypoint<Api>(target, "Api") binds the named Api export on the target Worker. The type argument is the class’s instance type (Api, not typeof Api):

alchemy.run.ts
import * as Cloudflare from "alchemy/Cloudflare";
import type { Api } from "./src/target.ts";
const target = yield* Cloudflare.Worker("Target", {
main: "./src/target.ts",
});
const caller = yield* Cloudflare.Worker("Caller", {
main: "./src/caller.ts",
env: {
API: Cloudflare.WorkerEntrypoint<Api>(target, "Api"),
},
});

InferEnv maps the binding to Cloudflare’s native Service<Api> type, which checks method arguments and converts return values to promises:

import type { CallerEnv } from "../alchemy.run.ts";
export default {
async fetch(request: Request, env: CallerEnv) {
return new Response(await env.API.greet("alice"));
},
};

Without the type argument the entry is a bare Fetcher service stub (fetch + connect only) and RPC calls do not type-check.

The options form attaches properties the target reads from this.ctx.props, workerd’s per-binding configuration channel. Output values resolve at deploy time:

env: {
VENDOR: Cloudflare.WorkerEntrypoint(vendorWorker, {
entrypoint: "Vendor",
props: { baseUrl: site.url },
}),
},

Compatibility flags and the compatibility date go on compatibility. nodejs_compat is the one you reach for most:

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

Workers Observability is on by default, with logs and invocation logs enabled. Pass observability to tune sampling, persist logs, or turn on traces. Effect-native Workers should provide Cloudflare.Telemetry() instead of setting traces by hand, since the Layer enables traces on the Worker and mirrors Effect.withSpan into the Cloudflare waterfall:

observability: {
logs: { enabled: true, invocationLogs: true, persist: true },
traces: { enabled: true, headSamplingRate: 1 },
},

A Tail Worker receives another Worker’s execution traces after each invocation. List it in tailConsumers, or in streamingTailConsumers to receive events live while the producer is still running:

const tail = yield* Cloudflare.Worker("Tail", { main: "./src/tail.ts" });
const api = yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
tailConsumers: [tail],
});

Put Cloudflare Access in front of the Worker with access. Unauthenticated requests are redirected to your team’s login page, and handlers read the identity from Cloudflare.Access.Context:

{
main: import.meta.url,
access: {
policies: [{ decision: "allow", include: [{ emailDomain: "example.com" }] }],
},
}

Pass an existing Cloudflare.Access.Application instead to share one policy set across several Workers. See Protect a Worker with Access.

main is bundled with rolldown at deploy time. Effect, Alchemy, and the cloud SDKs are marked pure so unused code prunes aggressively. Mark your own side-effect-free packages the same way with build.pure, or set bundle: false to upload a bundle another tool already produced, such as OpenNext, byte for byte:

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

Every prop, including limits, placement, logpush, crons, and tags, is documented on the Worker reference.

The Worker’s constructor runs once per isolate. The first event builds your Layers, and every later event reuses them. Each event then runs with a fresh Scope that closes after the response via ctx.waitUntil, so a finalizer added in a handler runs after the response is sent without blocking it:

fetch: Effect.gen(function* () {
yield* Effect.addFinalizer(() => flushMetrics().pipe(Effect.ignore));
return HttpServerResponse.text("ok");
}),

For ad-hoc background work, WorkerExecutionContext.waitUntil forks an Effect with the caller’s full context and keeps the invocation alive until it settles:

Effect.gen(function* () {
const exec = yield* Cloudflare.WorkerExecutionContext;
return {
fetch: Effect.gen(function* () {
yield* exec.waitUntil(writeAuditLog(event));
return HttpServerResponse.text("accepted", { status: 202 });
}),
};
});

Streaming responses and WebSocket upgrades transfer the request scope to the stream, so its finalizers run when the stream completes. workerd has no isolate-teardown hook, so a finalizer added in the constructor never runs. Acquire anything that needs cleanup, connections and pools, inside handlers. Drizzle.Postgres follows this pattern, one pool per event, and the SQL connection lifecycle spells out the contract. Instance scope vs request scope covers the model across all runtimes.

Every deploy uploads an immutable version, and by default it takes 100% of traffic. The version prop unlocks the other shapes. Split traffic to roll a deploy out gradually:

yield* Cloudflare.Worker("Api", {
main: "./src/api.ts",
version: { traffic: 25 },
});

Set preview.of to deploy this Worker as a Preview of another stage’s Worker (branch and pull-request testing, isolated Durable Objects). Set version.parent to upload a canary version of another stage’s Worker. Ramping, rollback, smoke testing with version overrides, and pinning users to a version are covered in Gradual deployments.

The version_metadata binding tells a running Worker which version it is, so responses and logs can say exactly which deploy produced them:

export default Cloudflare.Worker(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const versionMetadata = yield* Cloudflare.Workers.VersionMetadata();
return {
fetch: Effect.gen(function* () {
const { id, tag, timestamp } = yield* versionMetadata;
return yield* HttpServerResponse.json({ id, tag, timestamp });
}),
};
}).pipe(Effect.provide(Cloudflare.Workers.VersionMetadataBinding)),
);

Async Workers declare it on env as CF_VERSION_METADATA: Cloudflare.Workers.VersionMetadata(), and InferEnv types the entry as the native { id, tag, timestamp } object.

Guides that build on Workers:

Related:

Reference: