RPC comes to Cloudflare Workers

Cloudflare Workers now includes a native RPC system for Worker-to-Worker and Worker-to-Durable Object communication. Instead of stringing together HTTP requests with hand-rolled serialization, you define a class with methods and call them remotely. No schemas, no routers.

Here's the server side:

export class MyService extends WorkerEntrypoint {
  sum(a, b) {
    return a + b;
  }
}

And the client side:

let three = await env.MY_SERVICE.sum(1, 2);

The system is built on Cap'n Proto, but you never touch a schema. TypeScript is supported if you want it. The protocol and implementation are open source as part of workerd.

What makes it more than a remote call

Several capabilities push this beyond a simple request/response wrapper:

  • Structured Clonable types pass as parameters and return values — Dates work, and cyclic structures are fine.
  • Functions and objects with methods can be passed as arguments or returned. When the other side invokes them, a new RPC goes back to you.
  • Zero-latency calls to Workers over Service Bindings: the callee often runs in the same thread, so the overhead is close to a plain function call.
  • Pipelining across the network: when calling a Durable Object, you can speculatively chain method calls on the result within a single round trip.
  • Byte streams over RPC are supported, with automatic flow control.
  • Object-capability security governs what references can do.

The design goal is familiarity. RPC's old reputation for being broken stems from days when calls blocked synchronously and network failures crashed the stack. Promises, async/await, and proper exception handling fix those faults. Pipelining eliminates the round-trip penalty of chained calls. The result is that distributed programming maps onto the same mental model as local API calls.

Authentication service example

The canonical scenario: an app Worker needs to validate a user's cookie against a separate auth Worker. Before RPC, Service Bindings required you to speak HTTP, which meant constructing requests and parsing responses inline:

// OLD STYLE: HTTP-based service bindings.
export default {
  async fetch(req, env, ctx) {
    // Call the auth service to authenticate the user's cookie.
    // We send it an HTTP request using a service binding.

    // Construct a JSON request to the auth service.
    let authRequest = {
      cookie: req.headers.get("Cookie")
    };

    // Send it to env.AUTH_SERVICE, which is our service binding
    // to the auth worker.
    let resp = await env.AUTH_SERVICE.fetch(
        "https://auth/check-cookie", {
      method: "POST",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
      },
      body: JSON.stringify(authRequest)
    });

    if (!resp.ok) {
      return new Response("Internal Server Error", {status: 500});
    }

    // Parse the JSON result.
    let authResult = await resp.json();

    // Use the result.
    if (!authResult.authorized) {
      return new Response("Not authorized", {status: 403});
    }
    let username = authResult.username;

    return new Response(`Hello, ${username}!`);
  }
}

And the matching server handler:

// OLD STYLE: HTTP-based auth server.
export default {
  async fetch(req, env, ctx) {
    // Parse URL to decide what endpoint is being called.
    let url = new URL(req.url);
    if (url.pathname == "/check-cookie") {
      // Parse the request.
      let authRequest = await req.json();

      // Look up cookie in Workers KV.
      let cookieInfo = await env.COOKIE_MAP.get(
          hash(authRequest.cookie), "json");

      // Construct the response.
      let result;
      if (cookieInfo) {
        result = {
          authorized: true,
          username: cookieInfo.username
        };
      } else {
        result = { authorized: false };
      }

      return Response.json(result);
    } else {
      return new Response("Not found", {status: 404});
    }
  }
}

With RPC this collapses to a direct call:

// NEW STYLE: RPC-based service bindings
export default {
  async fetch(req, env, ctx) {
    // Call the auth service to authenticate the user's cookie.
    // We invoke it using a service binding.
    let authResult = await env.AUTH_SERVICE.checkCookie(
        req.headers.get("Cookie"));

    // Use the result.
    if (!authResult.authorized) {
      return new Response("Not authorized", {status: 403});
    }
    let username = authResult.username;

    return new Response(`Hello, ${username}!`);
  }
}

And the server side:

// NEW STYLE: RPC-based auth server.
import { WorkerEntrypoint } from "cloudflare:workers";

export class AuthService extends WorkerEntrypoint {
  async checkCookie(cookie) {
    // Look up cookie in Workers KV.
    let cookieInfo = await this.env.COOKIE_MAP.get(
        hash(cookie), "json");

    // Return result.
    if (cookieInfo) {
      return {
        authorized: true,
        username: cookieInfo.username
      };
    } else {
      return { authorized: false };
    }
  }
}

Classes with capabilities

The auth service can expose a richer API — user profiles, email notifications, activity logs — all guarded by the initial credential check. A checkCookie() call returns a User instance:

import { WorkerEntrypoint, RpcTarget } from "cloudflare:workers";

// `User` is an RPC interface to perform operations on a particular
// user. This class is NOT exported as an entrypoint; it must be
// received as the result of the checkCookie() RPC.
class User extends RpcTarget {
  constructor(uid, env) {
    super();

    // Note: Instance members like these are NOT exposed over RPC.
    // Only class (prototype) methods and properties are exposed.
    this.uid = uid;
    this.env = env;
  }

  // Get/set user profile, backed by Worker KV.
  async getProfile() {
    return await this.env.PROFILES.get(this.uid, "json");
  }
  async setProfile(profile) {
    await this.env.PROFILES.put(this.uid, JSON.stringify(profile));
  }

  // Send the user a notification email.
  async sendNotification(message) {
    let addr = await this.env.EMAILS.get(this.uid);
    await this.env.EMAIL_SERVICE.send(addr, message);
  }

  // Append to the user's activity log.
  async logActivity(description) {
    // (Please excuse this somewhat problematic implementation,
    // this is just a dumb example.)
    let timestamp = new Date().toISOString();
    await this.env.ACTIVITY.put(
        `${this.uid}/${timestamp}`, description);
  }
}

// Now we define the entrypoint service, which can be used to
// get User instances -- but only by presenting the cookie.
export class AuthService extends WorkerEntrypoint {
  async checkCookie(cookie) {
    // Look up cookie in Workers KV.
    let cookieInfo = await this.env.COOKIE_MAP.get(
        hash(cookie), "json");

    if (cookieInfo) {
      return {
        authorized: true,
        user: new User(cookieInfo.uid, this.env),
      };
    } else {
      return { authorized: false };
    }
  }
}

From the client, the flow is natural:

export default {
  async fetch(req, env, ctx) {
    // `using` is a new JavaScript feature. Check out the
    // docs for more on this:
    // https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/
    using authResult = await env.AUTH_SERVICE.checkCookie(
        req.headers.get("Cookie"));
    if (!authResult.authorized) {
      return new Response("Not authorized", {status: 403});
    }

    let user = authResult.user;
    let profile = await user.getProfile();

    await user.logActivity("You visited the site!");
    await user.sendNotification(
        `Thanks for visiting, ${profile.name}!`);

    return new Response(`Hello, ${profile.name}!`);
  }
}

And the worker config references the auth service as an entrypoint:

name = "app-worker"
main = "./src/app.js"

# Declare a service binding to the auth service.
[[services]]
binding = "AUTH_SERVICE"    # name of the binding in `env`
service = "auth-service"    # name of the worker in the dashboard
entrypoint = "AuthService"  # name of the exported RPC class

No client-side code is transferred. Every class instance passed over the wire is replaced by a stub that performs a new RPC back to the originating isolate:

BLOG-2378 Embedded Image - rWx2Zb

That stub is a JavaScript Proxy with a wildcard method. Method names are sent to the server at call time; if the method doesn't exist, the server throws.

Notice the security property woven into the design: a User reference is a capability. You cannot obtain one without presenting a valid cookie, and you cannot call methods on an object you haven't been handed. Capability-based security works because the API shape itself enforces access — no separate permission layer needed.

Named entrypoints

Service bindings previously always pointed at a Worker's default entrypoint, the export default handler that's also exposed publicly under workers.dev. That exposed surface was hard to trust for inter-service calls.

Named entrypoints fix this. Only Workers explicitly configured at deploy time can bind to them, and only within the same account:

export class AuthService extends WorkerEntrypoint {
entrypoint = "AuthService"  # name of the exported RPC class

Runtime discovery or spontaneous binding creation isn't possible. That means a named entrypoint can safely assume all callers are Workers you deployed with an explicit binding. Future work will add entrypoint lockdown, binding auditing, and runtime caller information — removing the need for internal auth code entirely.

Type safety with TypeScript

The RPC system is dynamically typed by default, mirroring JavaScript. Static typing is optional but supported. The @cloudflare/workers-types package provides Service<MyEntrypointType>, which transforms a server-side interface into the correct client shape — converting methods to async and stubbing out functions and RpcTargets.

You define MyEntrypointType in a shared file or extract it from server code using tsc --declaration, then apply it:

import { WorkerEntrypoint } from "cloudflare:workers";

// The interface that your server-side entrypoint implements.
// (This would probably be imported from a .d.ts file generated
// from your server code.)
declare class MyEntrypointType extends WorkerEntrypoint {
  sum(a: number, b: number): number;
}

// Define an interface Env specifying the bindings your client-side
// worker expects.
interface Env {
  MY_SERVICE: Service<MyEntrypointType>;
}

// Define the client worker's fetch handler with typed Env.
export default <ExportedHandler<Env>> {
  async fetch(req, env, ctx) {
    // Now env.MY_SERVICE is properly typed!
    const result = await env.MY_SERVICE.sum(1, 2);
    return new Response(result.toString());
  }
}

Calling Durable Objects directly

Durable Objects give you a named Worker instance that other Workers can reach over the network for coordination, each with its own private on-disk storage. In the past, talking to a Durable Object meant constructing HTTP requests and parsing HTTP responses. RPC removes that layer: declare methods on your Durable Object class and invoke them on the stub. The one requirement is that the class extends DurableObject:

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  async increment() {
    // Increment our stored value and return it.
    let value = await this.ctx.storage.get("value");
    value = (value || 0) + 1;
    this.ctx.storage.put("value", value);
    return value;
  }
}

Then the call is straightforward:

let stub = env.COUNTER_NAMESPACE.get(id);
let value = await stub.increment();

TypeScript works the same way when you type the binding as DurableObjectNamespace<ServerType>:

interface Env {
  COUNTER_NAMESPACE: DurableObjectNamespace<Counter>;
}

Skipping round trips with speculative calls

A Durable Object can live anywhere on the network relative to the caller, so every RPC crosses real distance. Chained calls that each wait on the previous result can quickly pile up round trips.

// Makes three round trips.
let foo = await stub.foo();
let baz = await foo.bar.baz();
let corge = await baz.qux[3].corge();

Workers RPC lets you avoid that serialization. If a call returns a stub and the only thing you need from it is another method invocation, you can skip the await:

// Same thing, only one round trip.
let foo = stub.foo();
let baz = foo.bar.baz();
let corge = await baz.qux[3].corge();

The trick is that RPC methods don't return normal promises. They return RPC promises — custom thenables you can use anywhere a regular Promise works, like await or .then(). But an RPC promise is also a proxy with a wildcard property. That lets you express speculative calls on the eventual result before it has resolved. Those calls are transmitted immediately, so the server can start executing them as soon as the first RPC finishes there — before the result has even traveled back to the client.

This is known as "Promise Pipelining." It is a standard capability of object-capability RPC systems like Cap'n Proto, though it isn't a security feature per se.

What a bindings marketplace could look like

Today, Service Bindings and Durable Objects only connect Workers on the same account, so RPC is limited to your own code. Cloudflare alone can add new binding types — Queues, KV, D1 — to the platform. The question is whether anyone could create and share their own binding type.

Previously that seemed to require auto-loading client libraries into the calling Worker, which would mean trusting someone else's code to run inside your isolate. RPC removes that trust problem: the binding only receives exactly what you explicitly pass it, and it can't touch the rest of your Worker. That opens the door to a bindings marketplace where developers offer rich JavaScript APIs to each other without sacrificing security — a direction Cloudflare says it wants to explore.

Available now

Workers RPC is live for all Workers users. See the documentation to start building with it.