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:
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:
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 }; }),);bun alchemy deployAlchemy 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.
Bind a resource
Section titled “Bind a resource”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:
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.
Call another Worker
Section titled “Call another Worker”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():
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:
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.
Serve static assets
Section titled “Serve static assets”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.
URLs and domains
Section titled “URLs and domains”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 domainworkersDev: { enabled: false, previewsEnabled: true }, // preview URLs onlyUnder 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.
The Worker’s own URL
Section titled “The Worker’s own URL”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 },});Async Workers
Section titled “Async Workers”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:
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>;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.
Named entrypoints
Section titled “Named entrypoints”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:
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):
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 }, }),},Configure the Worker
Section titled “Configure the Worker”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.
Background work and scopes
Section titled “Background work and scopes”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.
Versions
Section titled “Versions”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.
Where next
Section titled “Where next”Guides that build on Workers:
- Gradual deployments — previews, canaries, and ramped rollouts across versions.
- Python Workers — point
mainat a.pyfile. - Workers Cache — serve responses from the edge before the Worker runs.
- Rate limiting — throttle requests with a binding.
- Browser rendering — a headless browser as a binding.
- Worker Loader — run untrusted Workers at runtime.
- Workers for Platforms — run customers’ Workers in your account.
- Custom domains — serve Workers from your own hostnames and routes.
- Effect HTTP API and Effect RPC — schema-validated surfaces.
- Frontend frameworks — ship a frontend from the same Stack.
Related:
- Durable Objects, Workflows, and Containers — the other compute Runtimes, all reached through a Worker.
- Secrets & env — bind
.envvalues and secrets into the Worker.
Reference: