Workers
A Cloudflare Worker is serverless JavaScript running in every Cloudflare data center. In alchemy, a Worker is a special kind of Resource: it has both an infrastructure definition (name, bundle, compatibility flags) and a runtime implementation expressed as an Effect — one program describes what to deploy and what it does.
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 via bindings.
Deploy a Worker
Section titled “Deploy a Worker”A Worker follows a two-phase pattern: the outer Effect.gen is the
init phase (binds resources at deploy time), and the handlers it
returns are the runtime phase (run on each request).
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!"); }), }; }),);Wire it into 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.
Bindings
Section titled “Bindings”Workers reach other resources through bindings — typed clients
resolved in the init phase. Given an R2 bucket declared in its own
file (export const Bucket = Cloudflare.R2.Bucket("Bucket")), bind
it by yielding Cloudflare.R2.ReadWriteBucket(...) and providing
its binding layer:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";import { Bucket } from "./bucket.ts";
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { // init: register the binding, get a typed client const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return { fetch: Effect.gen(function* () { // runtime: use it const object = yield* bucket.get("hello.txt"); return HttpServerResponse.text( object === null ? "Not found" : yield* object.text(), ); }).pipe( Effect.catchTag("R2Error", (error) => Effect.succeed( HttpServerResponse.text(error.message, { status: 500 }), ), ), ), }; }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);Three things make this different from a wrangler.toml binding:
- The binding is declared where it’s used — deploying the Worker deploys the wiring.
- The client is fully typed, and its errors (like
R2Error) live in the Effect type system, so you can’t forget to handle them. - Access is scoped: request
ReadBucket,WriteBucket, orReadWriteBucketdepending on what the Worker actually needs.
Every building block follows the same shape — see D1, Queues, and Hyperdrive, or walk through it step by step in tutorial part 2.
Schemaless RPC
Section titled “Schemaless RPC”This is the Cloudflare shape of Schemaless RPC — see that page for the full concept.
Workers can call each other’s methods directly — no HTTP routes, no
schema declarations, no public URL. Declare the Worker as a class
whose type carries its RPC shape, and any method returning an
Effect becomes callable from other Workers. This is the
recommended way to do Worker-to-Worker RPC.
Define the callee with the class + .make() form (the class is a
lightweight identifier; .make() provides the runtime, and bundlers
tree-shake it out of consumers):
// src/WorkerB.ts — the tag carries the name + RPC shapeimport * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";
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* () { return { greet: (name: string) => Effect.succeed(`Hello ${name}`), }; }),);Bind it from another Worker with Cloudflare.Workers.bindWorker and
call its methods 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 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* () { const greeting = yield* b.greet("world"); return HttpServerResponse.text(greeting); }), }; }),) {}bindWorker(WorkerB) registers a service binding on WorkerA, so
each call travels over Cloudflare’s in-account service-binding
fabric — never the public internet — and b.greet("world") is
type-checked against WorkerB’s declared shape end-to-end.
The same pattern works for Durable Objects. For external clients across a trust boundary — where payloads need schema validation before they touch your code — reach for Effect RPC instead.
Isolate scope vs request scope
Section titled “Isolate scope vs request scope”The Worker’s init closure runs once per isolate: the bridge
builds your layers on the first event, and every later event —
fetch, RPC, cron, queue — reuses them. Each event then runs with a
fresh Scope that the bridge closes after the response via
ctx.waitUntil, so Effect.addFinalizer 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");}),Streaming responses and WebSocket upgrades transfer ownership of the request scope to the stream — its finalizers run when the stream completes instead of when the handler returns.
workerd has no isolate-teardown hook, so a finalizer added in the
init closure never runs — acquire anything that needs cleanup
(connections, pools) inside handlers, where it’s scoped to the
event. Drizzle.Postgres follows this pattern: one pool per event,
opened on the first query, closed when the event settles — the
SQL connection lifecycle spells out the
contract. See
Instance scope vs request scope
for the model across all runtimes.
URLs & domains
Section titled “URLs & domains”worker.urls is every URL that serves the Worker, most significant
first — and worker.url is always urls[0]:
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"]// worker.domain === { name: "example.com", aliases: ["www.example.com"],// redirects: ["old.example.com"] }Every hostname is attached as a Cloudflare custom domain — DNS records
and edge certificates are managed for you (the zone must already exist
in the account). A bare string is shorthand for { name }. Redirect
hostnames answer with a permanent redirect (path and query preserved)
before the Worker runs, so they never appear in urls.
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:
// worker.url === "http://localhost:1337"// worker.urls === ["http://localhost:1337", "http://192.168.0.12:1337"]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 the custom domains guide.
Static assets
Section titled “Static assets”A Worker can serve a directory of static files alongside its code.
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", }, // ...);Set assets.runWorkerFirst to route requests through the Worker
before asset matching — true for every request (the Worker then
serves static files itself via the ASSETS binding), or a glob array
to intercept only specific paths:
export default Cloudflare.Worker( "Worker", { main: import.meta.url, assets: { directory: "./public", // only these paths reach the Worker ahead of assets runWorkerFirst: ["/api/*", "/admin/*"], }, }, // ...);You rarely need this for SSR pages or API routes alone — a path that
matches no asset already falls through to the Worker. Reach for it
when a path the Worker must own could be answered by the asset layer
instead: an asset file shadowing a route, a
single-page-application fallback that would serve the app shell to
navigations, or a handler that intercepts asset requests outright
(true, serving files itself via the ASSETS binding).
The same routing applies under alchemy dev, so a path that hits
your handler locally hits it deployed too.
Omit main entirely to deploy an assets-only Worker — a static
site with no Worker code of your own:
const site = yield* Cloudflare.Worker("Site", { assets: { directory: "./public", htmlHandling: "drop-trailing-slash", notFoundHandling: "404-page", }, domain: "static.example.com",});Cloudflare’s asset layer serves every request and applies
htmlHandling / notFoundHandling (including
single-page-application fallback) itself — the same deployment shape
as an assets-only wrangler deploy. Static asset requests are free
and never invoke a Worker.
The class form works too — declare it in its own file and yield the class in your Stack:
export class Site extends Cloudflare.Worker<Site>()("Site", { assets: { directory: "./public", htmlHandling: "drop-trailing-slash", }, domain: "static.example.com",}) {}A _headers or _redirects file in the assets 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.
The Worker’s own URL
Section titled “The Worker’s own URL”A Worker often needs to know the URL it is served at — to build
absolute links, register webhooks, or hand a frontend its API
origin. 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 init phase; the
accessor it returns is yielded again inside a handler:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
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 resolved before the upload — the first custom
domain if one is configured, otherwise the Worker’s workers.dev
URL — and always equals the resource’s url attribute. Under
alchemy dev it resolves to the local dev server’s URL instead.
Async Workers declare it on env, where InferEnv types the
entry as string:
export const Worker = Cloudflare.Worker("Worker", { main: "./src/worker.ts", env: { PUBLIC_URL: Cloudflare.Worker.URL },});Because the URL exists before the build, a VITE_-prefixed entry
(VITE_PUBLIC_URL: Cloudflare.Worker.URL) is also inlined into a
Vite site’s client bundle — see
the site’s own URL.
Typed env for async Workers
Section titled “Typed env for async Workers”A Worker doesn’t have to be an Effect program. When main points at
a plain module — a classic async fetch handler, or a prebuilt
bundle from another tool — declare bindings with the env prop and
derive the runtime type with InferEnv:
import * as Cloudflare from "alchemy/Cloudflare";
export const Bucket = Cloudflare.R2.Bucket("Bucket");export const DB = Cloudflare.D1.Database("DB");
export const Worker = Cloudflare.Worker("Worker", { main: "./src/worker.ts", env: { Bucket, DB },});
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;InferEnv maps each env entry to its native workers-types
client — an R2 bucket becomes R2Bucket, a D1 database becomes
D1Database, a Durable Object (or a
Container,
which declares a container-backed DO class) becomes a typed
DurableObjectNamespace, and Config/Redacted values become
string. The handler stays plain JavaScript but the env can never
drift from the infrastructure that produced it:
import type { WorkerEnv } from "../alchemy.run.ts";
export default { async fetch(request: Request, env: WorkerEnv) { const object = await env.Bucket.get("hello.txt"); return new Response(object ? await object.text() : "Not found"); },};Named entrypoints
Section titled “Named entrypoints”Binding another Worker directly (env: { TARGET: worker }) targets its
default entrypoint. A Worker can export additional
WorkerEntrypoint classes —
workerd treats every named class export of an entry module as an
entrypoint. Bind one by name with Cloudflare.WorkerEntrypoint:
const caller = yield* Cloudflare.Worker("Caller", { main: "./src/caller.ts", env: { API: Cloudflare.WorkerEntrypoint(target, "Api"), },});InferEnv types the entry as a Fetcher service stub; RPC methods on
the target class are called on it directly (env.API.greet("alice")).
Entrypoint props
Section titled “Entrypoint props”The options form attaches properties the target entrypoint 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 }, }),}Versions & gradual deployments
Section titled “Versions & gradual deployments”Every deploy of a Worker uploads an immutable
version,
and a deployment routes traffic across up to two versions. By default
alchemy deploys each new version at 100%. The version prop unlocks the
other shapes: preview versions of another Worker, canaries, and gradual
rollouts of a Worker’s own deploys. The
Gradual deployments guide
covers the full rollout workflow — ramping, rollback, smoke testing
with version overrides, and pinning users to a version with version
affinity.
Preview versions
Section titled “Preview versions”Set version.parent to upload this Worker’s code as a version of
another Worker’s script instead of creating a script of its own. The
parent is usually a Worker deployed in another stage, referenced with
Worker.ref:
const parent = yield* Cloudflare.Worker.ref("Api", { stage: "staging" });
const preview = yield* Cloudflare.Worker("Api", { main: "./src/api.ts", version: { parent, message: `PR #${process.env.PR_NUMBER}` },});The version receives no traffic. preview.url is its aliased preview
URL (<alias>-<name>.<subdomain>.workers.dev) — alchemy derives a stable
alias from the stack, stage, and logical id (override it with
version.alias), so the link stays the same across re-deploys and always
serves the latest uploaded version, while the parent’s live traffic is
untouched — the PR-preview workflow. The per-version URL
(<version-prefix>-...) is also returned in urls. The version
carries its own bindings, so a preview stage binds its own KV namespaces,
D1 databases, and secrets for a fully isolated environment — and because
the aliased URL is known before upload, Worker.URL
works on version workers and resolves to it. parent also accepts a
literal script name as an escape hatch for scripts alchemy doesn’t manage.
Canary traffic
Section titled “Canary traffic”Give the version traffic to route a percentage of the parent’s
traffic to it, with the live version keeping the remainder:
yield* Cloudflare.Worker("Api", { main: "./src/api.ts", version: { parent, traffic: 10 },});Destroying the canary restores 100% of traffic to the live version. Cloudflare deployments split between at most two versions, so one canary can be active per script at a time.
Gradual rollouts
Section titled “Gradual rollouts”On a Worker that deploys as its own script (no version.parent),
version.traffic below 100 turns a deploy into a gradual rollout —
the new version takes that share and the previously-live version
keeps the rest:
yield* Cloudflare.Worker("Api", { main: "./src/api.ts", version: { traffic: 25 },});Raise the number and re-deploy to ramp; remove the prop (or set 100) to
promote fully. traffic: 0 uploads the version without deploying it.
The very first deploy of a script always goes to 100% since there is no
previous version to split with.
What a version carries
Section titled “What a version carries”A version snapshots code, bindings, compatibility settings, and cache configuration. Script-level settings — routes, custom domains, crons, tags, observability, logpush, placement, limits, and the workers.dev subdomain — apply immediately to all versions, so they belong to the parent and are rejected on a version worker. During a gradual rollout of a Worker’s own deploy, script-level settings keep their live values until the next full deploy.
Two Cloudflare platform limits to know: preview URLs require the script’s workers.dev subdomain (enabled by default, and Workers that implement Durable Objects don’t get preview URLs), and a version worker cannot host Durable Object or Workflow classes — their migrations would apply to the parent’s script. Host the classes on the parent and reference them cross-script instead.
Version metadata
Section titled “Version metadata”The version_metadata binding exposes the deployed Worker version —
id, tag, and timestamp — at runtime, so responses, logs, and
error reports can say exactly which deploy produced them. Yield
VersionMetadata() in the init phase and provide its layer; the
accessor it returns is yielded again inside a handler, where the
binding actually exists:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default Cloudflare.Worker( "VersionWorker", { 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 instead —
env: { 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 — canary and ramp deploys across versions instead of cutting over.
- Custom domains — serve Workers from your own hostnames and routes.
- Effect HTTP API — a
schema-validated HTTP API with
HttpApi. - Effect RPC — schema-typed procedures served from a Worker.
- Add a React SPA — ship a frontend from the same Stack.
- Frontend frameworks — TanStack Start, React Router, SolidStart, Vue, and static sites.
Related:
- Durable Objects — stateful instances behind your Worker.
- KV — low-latency key-value reads at the edge.
- Queues — background work with at-least-once delivery.
- Workflows — durable multi-step jobs that outlive a request.
- Secrets & env — bind
.envvalues and secrets into the Worker. - Domains — zones, DNS records, and certificates.
Reference: