Skip to content

Effect RPC

The HTTP API guide showed how to build REST-style endpoints with schema validation. Effect RPC takes a different angle — you define procedures instead of HTTP endpoints, and you get a fully typed client for free with no URL construction or manual serialization.

The transport is still HTTP under the hood, and both patterns produce the same HttpEffect type, so the wiring story is identical to the HTTP API guide:

  1. Define schemas outside. Domain types and tagged errors, importable by both server and client.
  2. Construct the service inside the Worker’s Init phase. RpcGroup.toLayer is pure construction — safe to call at plan time. Don’t yield* the running server; it can’t run without a request.
  3. Return { fetch } where fetch is the HttpEffect produced by RpcServer.toHttpEffect. Or use Cloudflare.RpcWorker and skip the { fetch } wrapper.
  4. Bonus: deploy and call the procedures from a typed client that shares the exact same RpcGroup value.
  5. For Durable Objects, Cloudflare.RpcDurableObjectNamespace serves the rpc group on the DO’s fetch and exposes namespace.getByName(id) as a typed RpcClient — see Durable Object RPC with RpcDurableObjectNamespace.

Domain model and error types — pure schemas, no runtime concerns:

src/task.ts
import * as Schema from "effect/Schema";
export class Task extends Schema.Class<Task>("Task")({
id: Schema.String,
title: Schema.String,
completed: Schema.Boolean,
}) {}
export class TaskNotFound extends Schema.TaggedClass<TaskNotFound>()(
"TaskNotFound",
{ id: Schema.String },
) {}
export class CreateTaskFailed extends Schema.TaggedClass<CreateTaskFailed>()(
"CreateTaskFailed",
{ message: Schema.String },
) {}

RPC errors are schema-backed tagged classes. The client receives them as typed values you can match on — not raw HTTP status codes.

Each Rpc.make declares one procedure: a name, a payload schema, a success schema, and an error schema. RpcGroup.make collects them into a single value that both the server and the client will share.

src/rpcs.ts
import * as Schema from "effect/Schema";
import { Rpc, RpcGroup } from "effect/unstable/rpc";
import { Task, TaskNotFound, CreateTaskFailed } from "./task.ts";
const getTask = Rpc.make("getTask", {
success: Task,
error: TaskNotFound,
payload: { id: Schema.String },
});
const createTask = Rpc.make("createTask", {
success: Task,
error: CreateTaskFailed,
payload: { title: Schema.String },
});
export class TaskRpcs extends RpcGroup.make(getTask, createTask) {}

TaskRpcs is just a value-level description. Nothing executes yet.

Create src/worker.ts with an empty Init phase:

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.path },
Effect.gen(function* () {
return {};
}),
);

The generator inside Cloudflare.Worker is the Init phase — it runs both at plan time and at runtime. Only do pure construction or resource-binding factories here; never yield* work that needs an incoming request.

Tasks need durable storage. Declare an R2Bucket resource and bind it inside Init — bind() returns a typed handle whose get / put / delete / list methods we’ll call from the handlers below.

src/bucket.ts
import * as Cloudflare from "alchemy/Cloudflare";
export const Tasks = Cloudflare.R2Bucket("Tasks");
import {
import Tasks
Tasks
} from "./bucket.ts";
export default
any
Cloudflare
.
any
Worker
(
"Worker",
{
main: string
main
: import.

The type of import.meta.

If you need to declare that a given property exists on import.meta, this type may be augmented via interface merging.

meta
.
ImportMeta.path: string

Absolute path to the source file

path
},
any
Effect
.
any
gen
(function* () {
const
const tasks: any
tasks
= yield*
any
Cloudflare
.
any
R2Bucket
.
any
bind
(
import Tasks
Tasks
);
return {};
}),
);

We’ll provide the runtime side of this binding (Cloudflare.R2BucketBindingLive) in step 3c when we wire up the fetch handler.

TaskRpcs.toLayer takes an Effect that returns one handler per procedure and produces a Layer. Like HttpApiBuilder.group, this is pure construction — it builds a value, it doesn’t run the server.

Don’t yield* TaskRpcs.toLayer(...) here. Building a layer is fine, but actually executing the procedures requires an incoming request. Init only constructs; the work happens later, on each fetch call.

any
Effect
.
any
gen
(function* () {
const
const tasks: any
tasks
= yield*
any
Cloudflare
.
any
R2Bucket
.
any
bind
(
any
Tasks
);
const
const handlersLayer: any
handlersLayer
=
any
TaskRpcs
.
any
toLayer
({
getTask: ({ id }: {
id: any;
}) => any
getTask
: ({
id: any
id
}) =>
any
Effect
.
any
gen
(function* () {
const
const object: any
object
= yield*
const tasks: any
tasks
.
any
get
(
id: any
id
);
if (!
const object: any
object
) {
return yield*
any
Effect
.
any
fail
(new
any
TaskNotFound
({
id: any
id
}));
}
return
any
Schema
.
any
decodeUnknownSync
(
any
Task
)(
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.parse(text: string, reviver?: (this: any, key: string, value: any) => any): any

Converts a JavaScript Object Notation (JSON) string into an object.

@paramtext A valid JSON string.

@paramreviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.

@throws{SyntaxError} If text is not valid JSON.

parse
(yield*
const object: any
object
.
any
text
()));
}).
any
pipe
(
any
Effect
.
any
orDie
),
createTask: ({ title }: {
title: any;
}) => any
createTask
: ({
title: any
title
}) =>
any
Effect
.
any
gen
(function* () {
const
const task: any
task
= new
any
Task
({
id: `${string}-${string}-${string}-${string}-${string}`
id
:
var crypto: Crypto
crypto
.
Crypto.randomUUID(): `${string}-${string}-${string}-${string}-${string}` (+1 overload)
randomUUID
(),
title: any
title
,
completed: boolean
completed
: false,
});
yield*
const tasks: any
tasks
.
any
put
(
const task: any
task
.
any
id
,
var JSON: JSON

An intrinsic object that provides functions to convert JavaScript values to and from the JavaScript Object Notation (JSON) format.

JSON
.
JSON.stringify(value: any, replacer?: (this: any, key: string, value: any) => any, space?: string | number): string (+1 overload)

Converts a JavaScript value to a JavaScript Object Notation (JSON) string.

@paramvalue A JavaScript value, usually an object or array, to be converted.

@paramreplacer A function that transforms the results.

@paramspace Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.

@throws{TypeError} If a circular reference or a BigInt value is found.

stringify
(
const task: any
task
));
return
const task: any
task
;
}).
any
pipe
(
any
Effect
.
any
catchTag
("R2Error", (
error: any
error
) =>
any
Effect
.
any
fail
(new
any
CreateTaskFailed
({
message: any
message
:
error: any
error
.
any
message
})),
),
),
});
return {};
}),

Each handler receives the typed payload and returns an Effect that either succeeds with the declared success schema or fails with the declared error schema. getTask uses Effect.orDie to turn unexpected R2 failures into 500s — TaskNotFound is the only client-visible error. createTask maps R2 failures into the declared CreateTaskFailed error so the client can match on it.

RpcServer.toHttpEffect converts the RpcGroup into an HttpEffect — exactly the type Workers expect for fetch. We provide two layers to it:

  • The handlersLayer we just built.
  • RpcSerialization.layerJson — tells the server to encode and decode messages as JSON. (Swap for layerNdjson, layerMsgPack, etc. as needed.)

Unlike the HTTP API approach, RPC doesn’t need HttpPlatform.layer or Etag.layer — the RPC server handles message framing internally.

return {
fetch: any
fetch
:
any
RpcServer
.
any
toHttpEffect
(
any
TaskRpcs
).
any
pipe
(
any
Effect
.
any
provide
(
any
handlersLayer
),
any
Effect
.
any
provide
(
any
RpcSerialization
.
any
layerJson
),
),
};
src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import { Tasks } from "./bucket.ts";
import { CreateTaskFailed, Task, TaskNotFound } from "./task.ts";
import { TaskRpcs } from "./rpcs.ts";
export default Cloudflare.Worker(
"Worker",
{ main: import.meta.path },
Effect.gen(function* () {
const tasks = yield* Cloudflare.R2Bucket.bind(Tasks);
const handlersLayer = TaskRpcs.toLayer({
getTask: ({ id }) =>
Effect.gen(function* () {
const object = yield* tasks.get(id);
if (!object) {
return yield* Effect.fail(new TaskNotFound({ id }));
}
return Schema.decodeUnknownSync(Task)(
JSON.parse(yield* object.text()),
);
}).pipe(Effect.orDie),
createTask: ({ title }) =>
Effect.gen(function* () {
const task = new Task({
id: crypto.randomUUID(),
title,
completed: false,
});
yield* tasks.put(task.id, JSON.stringify(task));
return task;
}).pipe(
Effect.catchTag("R2Error", (error) =>
Effect.fail(new CreateTaskFailed({ message: error.message })),
),
),
});
return {
fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerJson)),
),
};
}).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),
);

The Cloudflare.Worker + { fetch: RpcServer.toHttpEffect(...) } pattern shows up every time you build a worker whose entire surface is an RpcGroup. Cloudflare.RpcWorker is the same construct with two small affordances:

  • The schema lives in props next to main, declaring the rpc group on the worker resource itself.
  • The init Effect returns the RpcServer.toHttpEffect(...)-piped Effect directly — no { fetch } wrapper. The wrapper plugs it into the worker’s fetch for you.
src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import { Tasks } from "./bucket.ts";
import { CreateTaskFailed, Task, TaskNotFound } from "./task.ts";
import { TaskRpcs } from "./rpcs.ts";
export default class Worker extends Cloudflare.RpcWorker<Worker>()(
"Worker",
{ main: import.meta.path, schema: TaskRpcs },
Effect.gen(function* () {
const tasks = yield* Cloudflare.R2Bucket.bind(Tasks);
const handlersLayer = TaskRpcs.toLayer({
getTask: ({ id }) =>
Effect.gen(function* () {
const object = yield* tasks.get(id);
if (!object) {
return yield* Effect.fail(new TaskNotFound({ id }));
}
return Schema.decodeUnknownSync(Task)(
JSON.parse(yield* object.text()),
);
}).pipe(Effect.orDie),
createTask: ({ title }) =>
Effect.gen(function* () {
const task = new Task({
id: crypto.randomUUID(),
title,
completed: false,
});
yield* tasks.put(task.id, JSON.stringify(task));
return task;
}).pipe(
Effect.catchTag("R2Error", (error) =>
Effect.fail(new CreateTaskFailed({ message: error.message })),
),
),
});
return RpcServer.toHttpEffect(TaskRpcs).pipe(
Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerJson)),
);
}).pipe(Effect.provide(Cloudflare.R2BucketBindingLive)),
) {}

Two changes from the plain Cloudflare.Worker form:

  1. Cloudflare.RpcWorker<Worker>()(...) instead of Cloudflare.Worker<Worker>()(...) — same class X extends ... ergonomics; consumers yield* Worker to get the deploy handle.

  2. The init Effect returns the rpc server’s Effect directly. Compare:

    // Cloudflare.Worker
    return {
    fetch: RpcServer.toHttpEffect(TaskRpcs).pipe(
    Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerJson)),
    ),
    };
    // Cloudflare.RpcWorker — drop the `{ fetch }` wrapper
    return RpcServer.toHttpEffect(TaskRpcs).pipe(
    Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerJson)),
    );

Use whichever you prefer — they produce the same deployed Worker.

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(
"TaskRpc",
{ providers: Cloudflare.providers() },
Effect.gen(function* () {
const worker = yield* Worker;
return { url: worker.url };
}),
);
Terminal window
alchemy deploy

Because TaskRpcs is just a value, the same group drives a fully typed client — no codegen. client.createTask accepts { title: string } and returns Effect<Task, CreateTaskFailed>.

scripts/client.ts
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as HttpClient from "effect/unstable/http/HttpClient";
import { FetchHttpClient } from "effect/unstable/http/FetchHttpClient";
import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
import { TaskRpcs } from "../src/rpcs.ts";
const program = Effect.gen(function* () {
const client = yield* RpcClient.make(TaskRpcs);
const task = yield* client.createTask({ title: "Write docs" });
console.log("Created:", task.id);
const fetched = yield* client.getTask({ id: task.id });
console.log("Fetched:", fetched.title);
});
Effect.runPromise(
program.pipe(
Effect.provide(
Layer.mergeAll(
RpcClient.layerProtocolHttp({ url: process.env.TASK_RPC_URL! }),
RpcSerialization.layerJson,
FetchHttpClient.layer,
),
),
),
);

Get the URL from the deploy output and run it:

Terminal window
TASK_RPC_URL=https://your-worker.workers.dev bun scripts/client.ts

The errors are typed values: client.getTask returns Effect<Task, TaskNotFound>, and you can Effect.catchTag( "TaskNotFound", ...) to handle the missing case explicitly.

Durable Object RPC with RpcDurableObjectNamespace

Section titled “Durable Object RPC with RpcDurableObjectNamespace”

Cloudflare.RpcDurableObjectNamespace is the same pattern as RpcWorker, applied to a Durable Object. The DO serves the rpc server on its own fetch handler, and consumers see namespace.getByName(id) as a typed RpcClient directly — no manual client wiring.

This is preferable to alchemy’s built-in DO method bridge whenever your DO’s surface is naturally an rpc group, because both sides go through the same Schema codec end-to-end. The built-in bridge JSON.stringifys every method return value, which strips Schema.Class identity (e.g. an effect/ai Response.Usage instance becomes a plain struct). With RpcDurableObjectNamespace, the wire format is the rpc serialization (NDJSON by default), and Schema.decode reconstructs class instances on the consumer side.

The DO needs its own rpc group — the same shape as the outer one minus per-session identifiers (the DO instance is the session, so an id field would be redundant).

src/agent-rpcs.ts
import * as Schema from "effect/Schema";
import { Rpc, RpcGroup } from "effect/unstable/rpc";
const setTitle = Rpc.make("setTitle", {
success: Schema.Void,
payload: { title: Schema.String },
});
const getTitle = Rpc.make("getTitle", {
success: Schema.String,
payload: {},
});
export class CounterRpcs extends RpcGroup.make(setTitle, getTitle) {}

The outer Effect runs once per DO class definition. The inner Effect runs once per DO instance — that’s where you build the handlers layer and return the rpc server’s HttpEffect-producing Effect.

src/counter.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import { CounterRpcs } from "./agent-rpcs.ts";
export default class Counter extends Cloudflare.RpcDurableObjectNamespace<Counter>()(
"Counter",
{ schema: CounterRpcs },
Effect.gen(function* () {
// outer init: shared dependencies for all instances of this DO
return Effect.gen(function* () {
// per-instance init: state + handlers
const state = yield* Cloudflare.DurableObjectState;
const handlersLayer = CounterRpcs.toLayer({
setTitle: ({ title }) => state.storage.put("title", title),
getTitle: () =>
Effect.map(state.storage.get<string>("title"), (t) => t ?? ""),
});
return RpcServer.toHttpEffect(CounterRpcs).pipe(
Effect.provide(
Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson),
),
);
});
}),
) {}

The RpcSerialization layer must be layerNdjson if any rpc in the group is a streaming rpc (Rpc.make("...", { stream: true, ... })) — streaming rpcs need newline framing on the wire. For non-streaming groups, layerJson works too.

yield* Counter resolves to a value with getByName(id) typed as RpcClient<CounterRpcs>. Each rpc method is a typed Effect/Stream factory — exactly the same client you’d get from RpcClient.make, but built for you and routed through the DO stub’s fetch.

src/worker.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
import Counter from "./counter.ts";
import { CounterRpcs } from "./agent-rpcs.ts";
export default class Worker extends Cloudflare.RpcWorker<Worker>()(
"Worker",
{ main: import.meta.path, schema: CounterRpcs },
Effect.gen(function* () {
const counters = yield* Counter;
const handlersLayer = CounterRpcs.toLayer({
setTitle: ({ title }) =>
counters.getByName("global").setTitle({ title }),
getTitle: () => counters.getByName("global").getTitle({}),
});
return RpcServer.toHttpEffect(CounterRpcs).pipe(
Effect.provide(Layer.mergeAll(handlersLayer, RpcSerialization.layerNdjson)),
);
}),
) {}

Note counters.getByName("global").setTitle({ title }) — no yield*, no client construction. The namespace already exposes the rpc client. The same rule applies for streaming rpcs: the result of getByName(id).someStreamRpc(...) is a Stream directly.

If you’re migrating from alchemy’s classic DO methods (where the DO returns a handler object and consumers call do.getByName(id).foo(...) to get Effect-wrapped method invocations), the call site doesn’t change shape — only the contract of what crosses the wire:

Built-in DO bridgeRpcDurableObjectNamespace
Wire formatJSON.stringify per methodRpcSerialization codec
Class identityLost — Response.Usage → plain structPreserved via Schema.decode
Streaming returnsEncoded as RpcStreamEnvelope (JSONL bytes)Native RpcSchema.Stream
Typed errorsEncoded as RpcErrorEnvelopeNative rpc error schema

Use RpcDurableObjectNamespace whenever any of the values flowing across the DO boundary contain Schema.Class instances (most effect/ai, effect/unstable/ai, or custom schemas with Schema.Class<X>(...)).

Both approaches produce the same HttpEffect and wire into a Worker the same way. The difference is in how clients interact:

  • HTTP API — standard REST endpoints, any HTTP client can call them. Best for public APIs.
  • RPC — typed procedures, ideal for service-to-service communication where both sides share the schema definitions.

You can even combine them in the same Worker — serve an HTTP API for external consumers and RPC for internal services.

  • Schemas (Task, TaskNotFound, CreateTaskFailed) and the RPC group (TaskRpcs) live outside the Worker — pure descriptions, importable by clients.
  • The handlers are constructed inside the Worker’s Init phase closure via TaskRpcs.toLayer. We build a Layer but never yield* the running server.
  • The Worker’s surface is { fetch }, where fetch is the HttpEffect produced by RpcServer.toHttpEffect. The Cloudflare.RpcWorker wrapper drops the { fetch } boilerplate by accepting that Effect as the init return value directly.
  • For a Durable Object surface, Cloudflare.RpcDurableObjectNamespace serves the rpc group over the DO’s fetch and exposes namespace.getByName(id) as a typed RpcClient — preserving Schema.Class identity across the DO hop, unlike the built-in DO bridge.
  • The same TaskRpcs value drives a fully typed client via RpcClient.make, with errors as typed values rather than HTTP status codes.