Schemaless RPC
Schemaless RPC is the default for internal communication between the services you deploy together: expose methods on a runtime’s interface, bind it from another resource, and call them through a fully typed client — no schema, no runtime validation, no per-request decode cost.
The shape
Section titled “The shape”RPC is a feature of every
Functions & Servers
runtime — the interface its Effectful Constructor returns may
include methods alongside fetch:
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"); }), }; }),) {}The host class here happens to be Cloudflare.Worker, but the host
is incidental — the pattern is identical on every runtime. This page
covers what members are allowed, how the typed client arises, what
crosses the wire, and where the limits are.
What a member may be
Section titled “What a member may be”An RPC-callable member is a function returning an Effect:
return { greet: (name: string) => Effect.succeed(`hello ${name}`),};…or a function returning a Stream, which the caller consumes
with the usual Stream.* combinators:
return { greet: (name: string) => Effect.succeed(`hello ${name}`), tick: (n: number) => Stream.iterate(0, (i) => i + 1).pipe( Stream.take(n), Stream.schedule(Schedule.spaced("100 millis")), ),};That is the whole rule: a function, with any arguments, returning an
Effect or a Stream. Bare Effect and Stream members also satisfy the
type — fetch itself is one — but they serve the host rather than
remote callers, so declare your RPC surface as functions. Typed
failures flow to the caller
(errors over the wire below), and
requirements like RuntimeContext are satisfied by the deployed
runtime.
What the types reject
Section titled “What the types reject”A member that is a plain value, or a function returning a plain value, fails the Shape constraint:
// ❌ rejected — neither member is an Effect, a Stream, or returns one:return { version: "1.0", greet: (name: string) => `hello ${name}`,};
// ✅ accepted:return { version: () => Effect.succeed("1.0"), greet: (name: string) => Effect.succeed(`hello ${name}`),};The rejection happens at declaration, not at call time — it surfaces as an assignability error on the init Effect you pass to the host class, so a shape that compiles is a shape every caller can rely on.
The typed client
Section titled “The typed client”There is no schema because the class itself carries the type. class Greeter extends Cloudflare.Worker<Greeter>()(...) gives the class a construct signature — new (_: never): Shape & ... — that makes its instance type exactly the Shape the init Effect returns, and the value you get by yielding the class carries that Shape as a phantom type (Rpc<Shape> = { "~alchemy/rpc": Shape }) that binders read back. Binding the class produces the client:
// in another resource's Effectful Constructor:const target = yield* Cloudflare.Workers.bindWorker(Greeter);
// in the interface it returns:const greeting = yield* target.greet(name); // typed from Greeter's ShapebindWorker is the Cloudflare Worker-to-Worker binder; each runtime pair has its own bind callable — the hub pages list them, with full callers per pairing. The mental model is the same everywhere: the stub is a Proxy whose get-trap dispatches over the wire. Every property access is a remote call — the only exceptions are the host’s own fetch/connect surface, which pass through to the underlying binding.
How a call travels
Section titled “How a call travels”One wire protocol, two carriers. Where the platform provides a native structured-clone channel (Cloudflare service bindings and Durable Object stubs, for example), calls ride it directly and values move by structured clone. Everywhere else — Containers and MicroVMs among them — a generic fetch transport carries the call: each method invocation is a POST {base}/__rpc__/{name} whose body is the JSON-encoded argument array, and the response is a JSON value, a JSON error envelope, or an NDJSON stream flagged by a response header:
/** Path prefix under which RPC methods are dispatched. */export const RPC_PATH_PREFIX = "/__rpc__/";/** Response header flag marking an NDJSON streamed body (vs a JSON value). */export const RPC_STREAM_HEADER = "x-alchemy-rpc-stream";Per-call protocol overhead is sub-millisecond — benchmarked in the wire-protocol test suite.
Streams
Section titled “Streams”A stub call’s return value is both an Effect and a Stream. The stub can’t know synchronously which one the remote method returns — the call is asynchronous — yet its declared type mirrors the remote Shape verbatim, so the single value it hands back satisfies both interfaces:
// one stub, both call shapes:const greeting = stub.greet(name); // value method — yield* it as an Effectconst firstFive = stub.tick(n).pipe(Stream.take(5)); // streaming method — pipe it as a StreamStreams are pulled lazily under backpressure: an infinite remote Stream can be Stream.take(5)-ed, and the server stops producing when the client stops consuming. A returned Stream can be piped through any Stream.* combinator, including straight onto an HTTP response. Byte streams (Uint8Array) round-trip alongside JSON object streams. A failure mid-stream travels as a trailing error marker and surfaces on the consumer as RpcRemoteStreamError with the encoded inner error on .error; values emitted before the failure are delivered.
Errors over the wire
Section titled “Errors over the wire”Typed failures cross the wire intact — Effect.fail(new MyTaggedError(...)) on the callee is recoverable on the caller:
class KeyMissing extends Data.TaggedError("KeyMissing")<{ readonly key: string;}> {}
// callee — a method that fails with a tagged error:get: (key: string) => kv.get(key).pipe( Effect.flatMap((value) => value === null ? Effect.fail(new KeyMissing({ key })) : Effect.succeed(value), ), ),
// caller — `_tag` and `key` survive the wire; class identity does not:const value = yield* stub.get(key).pipe( Effect.catchTag("KeyMissing", (e) => Effect.succeed(`missing: ${e.key}`), ),);The codec rules: tagged errors keep _tag and all own enumerable fields, and arrive as plain structs — Effect.catchTag matches because it keys on _tag, but e instanceof KeyMissing is false; class identity does not survive. Plain Errors keep name, message, and stack. Calling an unknown method fails with RpcCallError. Defects are not enveloped — the call rejects and surfaces as RpcCallError.
Limits, and why there is no validation
Section titled “Limits, and why there is no validation”Arguments and results must survive the wire codec: structured clone on the native channel, JSON on the fetch transport. Stream elements must be JSON values or Uint8Array (binary is base64-tagged on the fetch transport so it round-trips). Functions and callbacks don’t serialize. Class identity is always stripped. And because every property access on a stub dispatches remotely (bar the host’s own fetch/connect pass-through), there are no local fields — the stub is only its methods.
There is zero runtime validation anywhere in the stub or the server. That is the point — no schema to maintain, no per-request decode cost — and the trade-off: nothing sanitizes what arrives. Both sides are your code, deployed together and typed from the same Shape, which is why that trade is safe internally. At a trust boundary — external callers, versioned APIs, untrusted input — reach for schema-validated Effect RPC instead.
Where next
Section titled “Where next”- Schemaless RPC on Cloudflare — every Cloudflare pairing: Worker → Worker, Worker → Durable Object, another script’s Durable Object, Containers, and async consumers.
- Schemaless RPC on AWS — Lambda → MicroVM, plus what AWS does not yet have a schemaless stub for.
- Effect RPC — schema-validated RPC for trust boundaries.