Skip to content

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.

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).

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

Wire it into 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.

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:

src/worker.ts
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, or ReadWriteBucket depending 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.

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 shape
import * 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:

src/WorkerA.ts
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.

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.

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 domain
workersDev: { enabled: false, previewsEnabled: true }, // preview URLs only

Under 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.

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.

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.

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:

alchemy.run.ts
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:

src/worker.ts
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");
},
};

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")).

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

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.

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.

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.

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.

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.

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.

Guides that build on Workers:

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 .env values and secrets into the Worker.
  • Domains — zones, DNS records, and certificates.

Reference: