1---
2title: Schemaless RPC
3description: The pattern behind typed, schema-free RPC — what an RPC member may be, how the typed client arises, how calls travel over the wire, and where the limits are.
4sidebar:
5 order: 1
6---
7
8Schemaless RPC is the default for internal communication between the
9services you deploy together: expose methods on a runtime's
10interface, bind it from another resource, and call them through a
11fully typed client — no schema, no runtime validation, no
12per-request decode cost.
13
14## The shape
15
16RPC is a feature of every
17[Runtime](/infrastructure-as-effects/runtime)
18runtime — the interface its Effectful Constructor returns may
19include methods alongside `fetch`:
20
21```typescript
22export default class Greeter extends Cloudflare.Worker<Greeter>()(
23 "Greeter",
24 { main: import.meta.url },
25 Effect.gen(function* () {
26 return {
27 greet: (name: string) => Effect.succeed(`hello ${name}`),
28 fetch: Effect.gen(function* () {
29 return HttpServerResponse.text("ok");
30 }),
31 };
32 }),
33) {}
34```
35
36The host class here happens to be `Cloudflare.Worker`, but the host
37is incidental — the pattern is identical on every runtime. This page
38covers what members are allowed, how the typed client arises, what
39crosses the wire, and where the limits are.
40
41## What a member may be
42
43An RPC-callable member is a **function returning an Effect**:
44
45```typescript
46return {
47 greet: (name: string) => Effect.succeed(`hello ${name}`),
48};
49```
50
51…or a **function returning a Stream**, which the caller consumes
52with the usual `Stream.*` combinators:
53
54```diff lang="typescript"
55return {
56 greet: (name: string) => Effect.succeed(`hello ${name}`),
57+ tick: (n: number) =>
58+ Stream.iterate(0, (i) => i + 1).pipe(
59+ Stream.take(n),
60+ Stream.schedule(Schedule.spaced("100 millis")),
61+ ),
62};
63```
64
65That is the whole rule: a function, with any arguments, returning an
66Effect or a Stream. Bare Effect and Stream members also satisfy the
67type — `fetch` itself is one — but they serve the host rather than
68remote callers, so declare your RPC surface as functions. Typed
69failures flow to the caller
70([errors over the wire](#errors-over-the-wire) below), and
71requirements like `RuntimeContext` are satisfied by the deployed
72runtime.
73
74### What the types reject
75
76A member that is a plain value, or a function returning a plain value, fails the Shape constraint:
77
78```typescript
79// ❌ rejected — neither member is an Effect, a Stream, or returns one:
80return {
81 version: "1.0",
82 greet: (name: string) => `hello ${name}`,
83};
84
85// ✅ accepted:
86return {
87 version: () => Effect.succeed("1.0"),
88 greet: (name: string) => Effect.succeed(`hello ${name}`),
89};
90```
91
92The rejection happens at declaration, not at call time — it surfaces as an assignability error on the constructor Effect you pass to the host class, so a shape that compiles is a shape every caller can rely on.
93
94## The typed client
95
96There 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 constructor 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:
97
98```typescript
99// in another resource's Effectful Constructor:
100const target = yield* Cloudflare.Workers.bindWorker(Greeter);
101
102// in the interface it returns:
103const greeting = yield* target.greet(name); // typed from Greeter's Shape
104```
105
106`bindWorker` 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.
107
108## How a call travels
109
110One 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:
111
112```typescript
113// packages/alchemy/src/Rpc.ts
114/** Path prefix under which RPC methods are dispatched. */
115export const RPC_PATH_PREFIX = "/__rpc__/";
116/** Response header flag marking an NDJSON streamed body (vs a JSON value). */
117export const RPC_STREAM_HEADER = "x-alchemy-rpc-stream";
118```
119
120Per-call protocol overhead is sub-millisecond — benchmarked in the wire-protocol test suite.
121
122## Streams
123
124A 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:
125
126```typescript
127// one stub, both call shapes:
128const greeting = stub.greet(name); // value method — yield* it as an Effect
129const firstFive = stub.tick(n).pipe(Stream.take(5)); // streaming method — pipe it as a Stream
130```
131
132Streams 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.
133
134## Errors over the wire
135
136Typed failures cross the wire intact — `Effect.fail(new MyTaggedError(...))` on the callee is recoverable on the caller:
137
138```typescript
139class KeyMissing extends Data.TaggedError("KeyMissing")<{
140 readonly key: string;
141}> {}
142
143// callee — a method that fails with a tagged error:
144get: (key: string) =>
145 kv.get(key).pipe(
146 Effect.flatMap((value) =>
147 value === null
148 ? Effect.fail(new KeyMissing({ key }))
149 : Effect.succeed(value),
150 ),
151 ),
152
153// caller — `_tag` and `key` survive the wire; class identity does not:
154const value = yield* stub.get(key).pipe(
155 Effect.catchTag("KeyMissing", (e) =>
156 Effect.succeed(`missing: ${e.key}`),
157 ),
158);
159```
160
161The 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 `Error`s keep `name`, `message`, and `stack`. Calling an unknown method fails with `RpcCallError`. Defects are not enveloped — the call rejects and surfaces as `RpcCallError`.
162
163## Limits, and why there is no validation
164
165Arguments 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.
166
167There 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](/apis/effect-rpc) instead.
168
169## Where next
170
171- [Schemaless RPC on Cloudflare](/cloudflare/apis/schemaless-rpc) — every Cloudflare pairing: Worker → Worker, Worker → Durable Object, another script's Durable Object, Containers, and async consumers.
172- [Schemaless RPC on AWS](/aws/apis/schemaless-rpc) — Lambda → MicroVM, plus what AWS does not yet have a schemaless stub for.
173- [Effect RPC](/apis/effect-rpc) — schema-validated RPC for trust boundaries.