RPC for the modern web

Cap'n Web is a new RPC protocol and implementation in pure TypeScript from Cloudflare. It's a spiritual sibling to Cap'n Proto, an RPC protocol created a decade ago, but built specifically for the web stack. Unlike its predecessor, Cap'n Web has no schemas and almost no boilerplate, working more like the JavaScript-native RPC system in Cloudflare Workers while still integrating cleanly with TypeScript.

The protocol is built around an object-capability RPC model, making it far more expressive than typical RPC systems. It supports bidirectional calling—clients can call servers and vice versa—along with passing functions and objects by reference. When you pass a function over RPC, the recipient gets a stub that makes a real RPC call back to invoke the function where it was created. The same works for objects that extend the marker type RpcTarget.

Under the hood, serialization is human-readable JSON with light pre- and post-processing. It runs over HTTP, WebSocket, and postMessage(), with extensibility for other transports. The implementation is cross-platform, working in major browsers, Cloudflare Workers, Node.js, and other modern JavaScript runtimes. The whole library compresses to under 10 kB with no dependencies, and is available open source under the MIT license.

Setting up a client is trivial:

import { newWebSocketRpcSession } from "capnweb";

// One-line setup.
let api = newWebSocketRpcSession("wss://example.com/api");

// Call a method on the server!
let result = await api.hello("World");

console.log(result);

A complete Cloudflare Worker RPC server looks like:

import { RpcTarget, newWorkersRpcResponse } from "capnweb";

// This is the server implementation.
class MyApiServer extends RpcTarget {
  hello(name) {
    return `Hello, ${name}!`
  }
}

// Standard Workers HTTP handler.
export default {
  fetch(request, env, ctx) {
    // Parse URL for routing.
    let url = new URL(request.url);

    // Serve API at `/api`.
    if (url.pathname === "/api") {
      return newWorkersRpcResponse(request, new MyApiServer());
    }

    // You could serve other endpoints here...
    return new Response("Not found", {status: 404});
  }
}

Why RPC matters again

RPC expresses network communication as regular function calls. Instead of formatting and parsing HTTP requests and responses REST-style, an RPC system provides a stub object on the client that stands in for the real server-side object. When you call a method on the stub, the system serializes the parameters, transmits them, invokes the method remotely, and sends back the return value.

The reputation of RPC suffered because early implementations were synchronous. Called 40 years ago, before Promises and async/await existed, RPC blocked threads waiting for replies. Network failures hung or crashed programs. That era is over. Modern asynchronous programming models handle latency gracefully, exceptions surface network failures, and promise pipelining collapses chains of calls into single round trips. Large distributed systems already rely on RPC daily.

Cap'n Web fits the mental model every programmer already has: APIs composed of function calls, not byte-stream protocols or REST endpoints. For interactive applications with real-time collaboration features or complex security boundaries, the model is particularly well-suited. The project is still new and experimental, so adopting it means embracing the cutting edge.

Batch calls without WebSocket overhead

WebSocket connections aren't always necessary. For one-off batches of calls, Cap'n Web supports HTTP batch mode:

import { newHttpBatchRpcSession } from "capnweb";

let batch = newHttpBatchRpcSession("https://example.com/api");

let result = await batch.hello("World");

console.log(result);

The server stays exactly the same as before. One caveat: after you await an RPC in a batch, the batch is finished and any remote references received through it become broken. You can still issue multiple calls in a single batch before awaiting:

let batch = newHttpBatchRpcSession("https://example.com/api");

// We can call make multiple calls, as long as we await them all at once.
let promise1 = batch.hello("Alice");
let promise2 = batch.hello("Bob");

let [result1, result2] = await Promise.all([promise1, promise2]);

console.log(result1);
console.log(result2);

Promise pipelining

In both batch and WebSocket modes, you can make calls dependent on results you haven't received yet. A call can use the promise of another call's result without awaiting it first, and the entire dependency chain travels in a single network round trip.

Say your API looks like:

class MyApiServer extends RpcTarget {
  getMyName() {
    return "Alice";
  }

  hello(name) {
    return `Hello, ${name}!`
  }
}

You can write:

let namePromise = batch.getMyName();
let result = await batch.hello(namePromise);

console.log(result);

The initial getMyName() returns a promise, but that promise is passed directly as the input to hello(). The client tells the server to insert the result of the first call into the parameters of the second.

The same trick works for method calls on the eventual result of a promise:

let batch = newHttpBatchRpcSession("https://example.com/api");

// Authencitate the API key, returning a Session object.
let sessionPromise = batch.authenticate(apiKey);

// Get the user's name.
let name = await sessionPromise.whoami();

console.log(name);

This works because promises returned by Cap'n Web aren't regular promises. They're JavaScript Proxy objects. Any method called on them becomes a speculative call on the eventual result, sent to the server immediately with instructions to invoke the method once the earlier call completes.

Capability-based security

The pipelining example highlights an important security property of the object-capability model. When authenticate() verifies an API key and returns an authenticated session object, the client can make calls on that object to perform authorized operations. Server code might look like:

class MyApiServer extends RpcTarget {
  authenticate(apiKey) {
    let username = await checkApiKey(apiKey);
    return new AuthenticatedSession(username);
  }
}

class AuthenticatedSession extends RpcTarget {
  constructor(username) {
    super();
    this.username = username;
  }

  whoami() {
    return this.username;
  }

  // ...other methods requiring auth...
}

The key insight: clients cannot forge session objects. The only way to obtain one is to successfully call authenticate(). Most RPC systems can't return a stub pointing at a fresh RPC object from a call, so they require repeating the API key with every function invocation. Cap'n Web makes authentication fit naturally into the RPC abstraction, and the pattern is type-safe—you can't call an authenticated method without first having an authenticated session object, because you'd have nothing to call it on.

This proves particularly valuable for WebSockets, where standard browser APIs don't allow authorization via headers or cookies. Authentication must happen in-band over the connection, which typically breaks the RPC abstraction by changing connection state. The authenticate() pattern avoids that problem cleanly.

TypeScript integration

Cap'n Web also plugs into TypeScript's type system. Declare an API interface once, implement it on the server, and call it from the client:

// Shared interface declaration:
interface MyApi {
  hello(name: string): Promise<string>;
}

// On the client:
let api: RpcStub<MyApi> = newWebSocketRpcSession("wss://example.com/api");

// On the server:
class MyApiServer extends RpcTarget implements MyApi {
  hello(name) {
    return `Hello, ${name}!`
  }
}

Type checking is end-to-end with auto-completed method names throughout the call chain. As with all TypeScript, no type checks happen at runtime—malicious clients could send wrongly-typed parameters. That caveat applies to any JSON-based API, and tools like Zod can help if runtime validation is needed. Future work includes adding runtime type checking based directly on TypeScript types.

Why GraphQL isn’t the only answer

GraphQL solved the classic REST “waterfall” problem by letting clients request multiple resources in a single query. Instead of three sequential HTTP calls:

GET /user
GET /user/friends
GET /user/friends/photos

….you get one query that returns everything at once.

That’s a real win over REST, but it comes with tradeoffs:

  • New machinery. You need GraphQL’s schema language, server implementation, and client tooling — a lot of overhead for a JavaScript shop.
  • Weak composability. Declarative queries are great for reads but awkward for chained operations. You can’t easily say “create a user, then use that new user to send a friend request” in a single round trip.
  • Different mental model. GraphQL doesn’t look like the JavaScript APIs developers use daily. It’s a new abstraction rather than an extension of existing patterns.

Pipelining without a new language

Cap’n Web attacks the waterfall problem from a different angle. It stays entirely in JavaScript — no query language, no schemas, no separate ecosystem. Because it supports promise pipelining and object references, you can write code like this:

let user = api.createUser({ name: "Alice" });
let friendRequest = await user.sendFriendRequest("Bob");

Behind the scenes, both calls collapse into a single network round trip:

  1. Create the user.
  2. Grab the result — a new User object.
  3. Call sendFriendRequest() on it.

That’s expressed as ordinary JavaScript method calls. No declarations, no special tooling, no impedance mismatch with normal programming patterns.

Handling lists without extra round trips

To seriously rival GraphQL, though, Cap’n Web needed one more thing: a way to map over arrays remotely. GraphQL is often used for patterns like “list each friend, then fetch that friend’s profile photo.” That requires an array.map() that doesn’t add network latency.

Cap’n Proto never supported that. Cap’n Web now does:

let user = api.authenticate(token);

// Get the user's list of friends (an array).
let friendsPromise = user.listFriends();

// Do a .map() to annotate each friend record with their photo.
// This operates on the *promise* for the friends list, so does not
// add a round trip.
// (wait WHAT!?!?)
let friendsWithPhotos = friendsPromise.map(friend => {
  return {friend, photo: api.getUserPhoto(friend.id))};
}

// Await the friends list with attached photos -- one round trip!
let results = await friendsWithPhotos;

The trick is how .map() works. Normally, passing a function over RPC sends it by reference — the remote side gets a stub that calls back to the client. That would be useless here: the server would have to round-trip to the client for every array element. Instead, .map() is special. It sends a restricted, non-Turing-complete set of instructions for the server to execute on each element. For this case, those instructions are:

  1. Call api.getUserPhoto(friend.id).
  2. Return {friend, photo}, where friend is the original element and photo is the result.

The implementation uses record-replay. On the client, the callback runs once against a placeholder RPC promise. Since the callback must be synchronous, it can’t await — it can only make pipelined calls, which the runtime intercepts and records. Those recorded instructions are then replayed server-side. Because the recording is built from pipelining primitives, the instruction “language” for .map() turns out to be the RPC protocol itself.

Protocol internals

JSON with escape hatches

Cap’n Web speaks JSON, with a preprocessing layer for special types. Arrays act as “escape sequences.” For instance, JSON has no Date type, but Cap’n Web does — you might see a message like this:

{
  event: "Birthday Week",
  timestamp: ["date", 1758499200000]
}

To pass a literal array, it’s double-wrapped: [[]]. An array whose first element is a type name decodes to an instance of that type, using the remaining elements as parameters. Supported types are limited to structured-cloneable types plus RPC stub types. On top of this encoding sits a simplified RPC protocol inspired by Cap’n Proto.

Symmetric RPC model

The protocol is symmetric — there’s no fixed client or server. Two parties (call them Alice and Bob) exchange JSON messages over any bidirectional stream, WebSocket or otherwise. Each maintains an “export table” of objects exposed to the other side and an “import table” of references received. Exports carry signed integer IDs, like POSIX file descriptors, except IDs can be negative and are never reused.

Both sides start by exporting interface 0 as their “main” interface. A server typically exports its public RPC API there; a client exports an empty interface. New exports come from two paths:

  • When a message from Alice contains an object or function reference, Alice adds it to her export table with a negative ID, starting at -1 and counting down.
  • Alice can send a “push” message telling Bob to evaluate an expression and export the result. These get positive IDs starting at 1, allocated incrementally. Because Alice assigns these IDs predictably, she can reference the results before Bob even finishes evaluating — that’s the pipelining.

After a “push,” Alice may optionally send a “pull” requesting the result as a “resolve” or “reject” message. If she only needs the result for further pipelined calls, she can skip the pull entirely. In practice, the implementation sends a “pull” only when the application actually awaits a promise. So this code:

let namePromise = api.getMyName();
let result = await api.hello(namePromise);

console.log(result);

…produces a message exchange like this:

// Call api.getByName(). `api` is the server's main export, so has export ID 0.
-> ["push", ["pipeline", 0, "getMyName", []]
// Call api.hello(namePromise). `namePromise` refers to the result of the first push,
// so has ID 1.
-> ["push", ["pipeline", 0, "hello", [["pipeline", 1]]]]
// Ask that the result of the second push be proactively serialized and returned.
-> ["pull", 2]
// Server responds.
<- ["resolve", 2, "Hello, Alice!"]

Full protocol details are in the docs.

Production use and availability

Cap’n Web is experimental but already in production: it powers the recently launched “remote bindings” feature in Wrangler, letting a local workerd instance talk RPC to production services. The project is open source on GitHub, and more frontend-focused work is planned.

BLOG-2954 2