Functions & Servers
Cloudflare Workers, AWS Lambda Functions, ECS Tasks, and Containers are Resources that ship runtime code along with their infrastructure. When you declare one you describe both:
- The cloud configuration (memory, region, compatibility flags…)
- The Effect that runs inside it
Both deploy together as one resource.
The Effectful Constructor pattern
Section titled “The Effectful Constructor pattern”Every Function and Server is declared with the same shape — an Effect that declares its Bindings, then returns its interface:
Effect.gen(function* () { // 1. Declare Bindings — the resources this program uses. const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
// 2. Return the interface — the Effects and functions it exposes. return { fetch: Effect.gen(function* () { /* use bucket */ }), };}); // + Effect.provide(<the binding Layer>) — see BindingsThis is the Effectful Constructor — one simple, repeatable pattern you’ll use everywhere across alchemy:
- A Worker or Lambda Function returns its handlers (
fetch). - A Durable Object returns its RPC methods.
- A Workflow returns its run function.
- A Layer returns a service interface.
Bind what you need, return what you expose. Learn it once and every runtime in alchemy is a variation on it.
fetch is the interface member that serves HTTP — a per-request
Effect. Yield HttpServerRequest to read the request; return an
HttpServerResponse:
import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
Effect.gen(function* () { return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; return HttpServerResponse.text(`hello from ${request.url}`); }), };});Every Worker and Lambda Function serves HTTP through this member — it runs per request, inside the deployed Function (Phases covers exactly when).
The interface isn’t limited to fetch — add methods returning
Effect or Stream, and any resource that binds this Worker calls
them through a typed client:
export default class Greeter extends Cloudflare.Worker<Greeter>()( "Greeter", { main: import.meta.url }, Effect.gen(function* () { return { greet: (name: string) => Effect.succeed(`hello ${name}`), fetch: Effect.gen(function* () { return HttpServerResponse.text("ok"); }), }; }),) {}No schema, no runtime validation — serialization is automatic and the client type is inferred from the class. This is Schemaless RPC, the default for internal calls; the APIs section covers it plus the schema’d surfaces (Effect RPC, Effect HTTP) for trust boundaries.
A Worker, end to end
Section titled “A Worker, end to end”Cloud configuration, a Binding, and a fetch handler together:
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( "Api", { main: import.meta.url, compatibility: { date: "2026-01-28" }, observability: { enabled: true }, }, Effect.gen(function* () { // Init: bind a resource. The binding is the typed SDK. const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);
return { // Runtime: per-request handler. 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.provide(Cloudflare.R2.ReadWriteBucketBinding)),);The props (compatibility, observability, memory, routes…)
are the cloud configuration; the Effect is the Effectful Constructor.
One deploy ships both.
Three ways to declare one
Section titled “Three ways to declare one”Inline — no class, the form used above. Fine when nothing else needs to reference the Worker by name:
export default Cloudflare.Worker("Api", { main: import.meta.url }, effect);Inline class — the same declaration, but the class name makes
the type nominal instead of structural: hovers, errors, and Stack
outputs say Api instead of an anonymous shape:
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, effect,) {}Tagged — split identity from implementation. The class declares
only the Tag; .make(props, effect) produces the implementation
Layer:
// src/Api.ts — the Tag: a lightweight identifier others bind toexport class Api extends Cloudflare.Worker<Api, {}>()("Api") {}
// same file, or a separate one — the implementation Layerexport default Api.make( { main: import.meta.url }, Effect.gen(function* () { /* the Effectful Constructor */ }),);The implementation Layer is provided to the Stack, not to another Worker:
import ApiLive, { Api } from "./src/Api.ts";
export default Alchemy.Stack( "MyApp", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const api = yield* Api; return { url: api.url }; }).pipe(Effect.provide(ApiLive)),);The split matters because other Functions bind to the Tag —
never the implementation. Importing a Tag pulls in no runtime code,
so a Worker binding to Api doesn’t bundle Api’s implementation —
a piece of code with its own dependencies that may not even target
the same runtime (workerd vs node vs bun, serverless vs serverful).
It also allows multiple implementations of one Tag: ship the Tag,
and let each consumer provide the Layer that fits. This split is
what makes
Circular Bindings
possible.
Bindings in action
Section titled “Bindings in action”The yield* ReadWriteBucket(Bucket) line above is a binding —
it connects a resource to the Function’s runtime:
const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket);const kv = yield* Cloudflare.KV.ReadWriteNamespace(Sessions);
// Inside fetch:yield* bucket.put("key", "value");yield* kv.get("session-id");Every binding obeys the same shape across providers — a Cloudflare
R2 binding and an AWS S3 binding are interchangeable from the
caller’s point of view. Every binding is a contract (the yield*)
plus an interchangeable implementation Layer (the Effect.provide) —
Bindings
covers the deploy-time mechanics.
Effect handlers vs async handlers
Section titled “Effect handlers vs async handlers”Workers and Lambda Functions support two styles for the runtime code. Both deploy through the same provider and produce the same artifact.
Effect style — handlers are Effects, with typed errors, composable retries, structured concurrency, and bindings resolved through Effect’s context:
export default Cloudflare.Worker( "Worker", { main: import.meta.url }, Effect.gen(function* () { const bucket = yield* Cloudflare.R2.ReadWriteBucket(Bucket); return { fetch: Effect.gen(function* () { /* ... */ }), }; }),);Async style — handlers are standard async fetch functions.
Bindings are declared on the resource’s env prop and typed via
InferEnv:
export type WorkerEnv = Cloudflare.InferEnv<typeof Worker>;
export const Worker = Cloudflare.Worker("Worker", { main: "./src/worker.ts", env: { Bucket },});import type { WorkerEnv } from "../alchemy.run.ts";
export default { async fetch(request: Request, env: WorkerEnv) { const object = await env.Bucket.get("key"); return new Response(object?.body ?? null); },};Pick whichever fits your team. The Effect style unlocks Layers, structured retries, and fine-grained testing; the async style integrates better with existing handler code.
Instance scope vs request scope
Section titled “Instance scope vs request scope”The Effectful Constructor splits every Function and Server into two
lifetimes, and each has its own Scope:
| Scope | Covers | Closes |
|---|---|---|
| Instance scope | the constructor (init) and its Layers | workerd: never · Lambda: 500 ms at shutdown · servers: on exit |
| Request scope | one event — a request, RPC call, run, invocation | when the event settles |
The constructor runs once per instance — a Worker isolate, a Lambda sandbox, a server process. Everything it builds (bindings, SDK clients, Layers) is assembled once and reused by every event. One-shot I/O that produces a plain value is fine here — fetching a secret and caching it for a client, reading config — that’s exactly what once-per-instance is for. What doesn’t belong at init is anything disposable: connections, pools, streams, or any resource with a finalizer. The instance scope closes at shutdown at best (never on workerd), so cleanup registered here is best-effort or never — and on workerd, I/O objects (sockets, response bodies) are pinned to the request that created them and can’t be reused across events anyway.
Each event then runs your handler with a fresh Scope. Anything
disposable belongs here — acquired lazily, released when the event
settles:
fetch: Effect.gen(function* () { // workerd: runs after the response via ctx.waitUntil, without // blocking it. Lambda: runs before the response — keep it cheap. yield* Effect.addFinalizer(() => releaseThings().pipe(Effect.ignore)); return HttpServerResponse.text("ok");}),When request finalizers run differs by runtime. workerd closes the
scope into ctx.waitUntil, so they run after the response without
blocking it. Lambda settles the scope inline, before the response
leaves — a buffered invocation’s response isn’t released until the
Invoke phase completes, and no deferral scheme (extension windows,
dangling promises across freezes) is reliable, so the contract is
blocking and honest: keep Lambda request finalizers cheap, and write
anything that must not be lost durably inside the handler instead of
flushing it from a finalizer.
Resources that should live for the whole event but be shared across
calls within it — a database pool, for example — are memoized on the
request scope. Drizzle.postgres opens its pool on the first query
of an event and closes it when the event’s scope does; every query
in that event reuses it.
Why instance finalizers differ by runtime
Section titled “Why instance finalizers differ by runtime”workerd (Workers, Durable Objects, Workflows) has no teardown
hook. The platform freezes or evicts an isolate without warning,
so the instance scope exists but is never closed — a finalizer
registered during init will never run. This is a platform
constraint, not an alchemy choice: it’s the same reason a
module-level setInterval or open socket in a plain Worker never
gets cleaned up.
Lambda grants a brief Shutdown phase — because alchemy asks for
one. A sandbox with no registered extensions is killed with no
signal at all; alchemy’s generated entry registers an internal
extension with the Extensions API, which makes Lambda send SIGTERM
and allow 500 ms before SIGKILL. The entry closes the instance
scope in that window, so init-level finalizers run at sandbox
spin-down — best-effort and fast: half a second, not delivered on
hard failures (a timeout reset kills the runtime without the
signal). Flush caches and close connections here; never depend on it
for critical writes.
Servers (Containers, ECS Tasks) are real processes. The program runs under a root scope that closes when the process shuts down gracefully, so instance-scoped resources — a connection kept warm across requests, a background consumer — are released on exit. (A hard kill still skips finalizers, as it would in any process.)
The rule of thumb:
- Instance scope — build things. Cheap, reusable, no required cleanup.
- Request scope — use things. I/O, connections, finalizers.
- Instance-level cleanup is best-effort at most — 500 ms on Lambda, graceful-exit-only on servers, nonexistent on workerd. Design so nothing needs it.
Where next
Section titled “Where next”- Bindings — the deploy-time
mechanics behind
yield* ReadWriteBucket(Bucket): IAM, env injection, typed SDKs. - RPC — how bound resources call the methods you return.
- Phases — when the constructor runs vs the interface it returns, and why.
- Custom Runtime — bring the Functions & Servers model to a new compute target.