writing/tutorial/2026/06
TutorialJun 21, 2026·26 min read

Cap'n Web: Type-Safe RPC with Promise Pipelining in TypeScript

Learn how to build a type-safe, dependency-free RPC layer between your browser and server using Cap'n Web. This hands-on guide covers RpcTarget servers, HTTP batch and WebSocket clients, promise pipelining, callbacks, and the magic .map() method.

If you have ever called a REST endpoint just to get an ID, then called another endpoint to use that ID, you already know the pain of the network waterfall. Each round trip adds latency, and across a slow mobile connection in the MENA region those round trips stack up fast. Cap'n Web is Cloudflare's JavaScript-native RPC system, released in late 2025, that collapses those chains into a single round trip — without a schema compiler, without code generation, and in under 10 kB with zero dependencies.

In this tutorial you'll build a small but complete RPC API and consume it from a TypeScript client. Along the way you'll learn the parts that make Cap'n Web genuinely different from tRPC or plain fetch: promise pipelining, pass-by-reference objects, callbacks that run on the server, and the surprising .map() method.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (Cap'n Web relies on modern ES features)
  • Comfort with TypeScript and async/await
  • Basic understanding of HTTP and WebSockets
  • A code editor (VS Code recommended)

You do not need a Cloudflare account. Cap'n Web runs in Node.js, Deno, Bun, browsers, and Cloudflare Workers — we'll use Node.js for the server so everything runs locally.

What You'll Build

A minimal authenticated user API with three capabilities:

  1. A public method to authenticate with a token, returning an authenticated session object.
  2. Methods on that session to read the user's ID and friend list.
  3. A profile lookup that we'll chain to the session in a single network round trip using promise pipelining.

By the end you'll have a server (server.ts), a shared type contract (api.ts), and a client (client.ts) that demonstrates batching, pipelining, and .map().

What Makes Cap'n Web Different

Most RPC tools (tRPC, oRPC, gRPC-web) send one request per call, or require you to manually batch. Cap'n Web models remote objects as stubs: when you call a method, you instantly get back a promise that represents a future value living on the server. You can pass that future into the next call before it resolves. Cap'n Web records the whole dependency graph and ships it to the server as one message — the server runs the chain locally and returns only the final results.

That is promise pipelining, an idea borrowed from Cap'n Proto. It's the headline feature, and we'll build up to it step by step.

Step 1: Project Setup

Create a new project and install the single dependency:

mkdir capnweb-demo && cd capnweb-demo
npm init -y
npm install capnweb
npm install -D typescript tsx @types/node ws @types/ws

We use ws for the WebSocket server and tsx to run TypeScript files directly. Create a tsconfig.json with modern settings — using declarations require ES2022 or later for the disposal symbols:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2023", "DOM"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Set the package type to ESM so imports work as written:

{
  "type": "module"
}

Step 2: Define the Shared API Contract

Cap'n Web has no schema language — your TypeScript interfaces are the contract. Create api.ts and describe the shape of the remote objects. Both the server and client import these types, which is what gives you end-to-end type safety with zero code generation.

// api.ts
 
export interface UserProfile {
  id: number;
  name: string;
  bio: string;
}
 
// The authenticated session, returned after login.
// Methods here run remotely on the server.
export interface AuthedApi {
  getUserId(): number;
  getFriendIds(): number[];
}
 
// The public entry point exposed at the RPC endpoint.
export interface PublicApi {
  authenticate(token: string): AuthedApi;
  getUserProfile(userId: number): UserProfile;
}

Notice that authenticate returns AuthedApi — another object, not plain data. That's the key to pipelining: the returned session is a remote reference you can keep calling into.

Step 3: Implement the Server

A server object extends RpcTarget. Only its prototype methods and getters are exposed over RPC; instance fields stay private. Create server.ts:

// server.ts
import http from "node:http";
import { WebSocketServer } from "ws";
import {
  RpcTarget,
  newWebSocketRpcSession,
  nodeHttpBatchRpcResponse,
} from "capnweb";
import type { PublicApi, AuthedApi, UserProfile } from "./api.ts";
 
// Fake data store
const USERS: Record<number, UserProfile> = {
  1: { id: 1, name: "Amira", bio: "Backend engineer in Tunis" },
  2: { id: 2, name: "Youssef", bio: "Designer in Riyadh" },
  3: { id: 3, name: "Lina", bio: "PM in Casablanca" },
};
 
// The authenticated session — pass-by-reference because it extends RpcTarget.
class AuthedSession extends RpcTarget implements AuthedApi {
  // The userId is stored on the instance, so it stays private to the server.
  constructor(private readonly userId: number) {
    super();
  }
 
  getUserId(): number {
    return this.userId;
  }
 
  getFriendIds(): number[] {
    // Pretend everyone is friends with the next two users.
    return [this.userId + 1, this.userId + 2].filter((id) => USERS[id]);
  }
}
 
// The public entry point.
class PublicServer extends RpcTarget implements PublicApi {
  authenticate(token: string): AuthedApi {
    // In real code, verify a JWT or session token here.
    if (!token.startsWith("user-")) {
      throw new Error("Invalid token");
    }
    const userId = Number(token.slice("user-".length));
    if (!USERS[userId]) {
      throw new Error("Unknown user");
    }
    // Returning an RpcTarget hands the client a live remote reference.
    return new AuthedSession(userId);
  }
 
  getUserProfile(userId: number): UserProfile {
    const profile = USERS[userId];
    if (!profile) {
      throw new Error(`No profile for user ${userId}`);
    }
    return profile;
  }
}
 
// Serve HTTP batch on /api and WebSocket on the same port.
const httpServer = http.createServer(async (req, res) => {
  if (req.url === "/api") {
    try {
      await nodeHttpBatchRpcResponse(req, res, new PublicServer(), {
        headers: { "Access-Control-Allow-Origin": "*" },
      });
    } catch (err) {
      res.writeHead(500, { "content-type": "text/plain" });
      res.end(String((err as Error)?.stack ?? err));
    }
    return;
  }
  res.writeHead(404);
  res.end("Not Found");
});
 
// Attach a WebSocket server for long-lived sessions.
const wsServer = new WebSocketServer({ server: httpServer });
wsServer.on("connection", (ws) => {
  // A fresh PublicServer per connection.
  newWebSocketRpcSession(ws as unknown as WebSocket, new PublicServer());
});
 
httpServer.listen(8080, () => {
  console.log("Cap'n Web server on http://localhost:8080/api");
});

Two transports, one server class:

  • nodeHttpBatchRpcResponse handles HTTP batch requests — perfect for stateless, one-shot batches of calls.
  • newWebSocketRpcSession handles a long-lived WebSocket — perfect when the client makes calls over time or the server needs to call back.

Run it:

npx tsx server.ts

Tip: Each WebSocket connection gets its own new PublicServer(). Keep per-connection state (like the authenticated session) on instances, never on module-level globals, so two clients never see each other's data.

Step 4: A Basic Client and HTTP Batching

Now the client. Create client.ts and start with the simplest case — two independent calls bundled into one HTTP request:

// client.ts
import { newHttpBatchRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
 
async function basicBatch() {
  // 'using' auto-disposes the session when the block exits.
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
 
  // Start both calls WITHOUT awaiting — they queue up.
  const aliceProfile = api.getUserProfile(1);
  const youssefProfile = api.getUserProfile(2);
 
  // Awaiting flushes the batch: both calls travel in ONE HTTP request.
  const [a, y] = await Promise.all([aliceProfile, youssefProfile]);
  console.log(a.name, "/", y.name); // Amira / Youssef
}
 
basicBatch();

The using keyword is JavaScript's explicit resource management. When api leaves scope, its connection is disposed automatically — no try/finally needed. With the HTTP batch transport, the request is only sent once you await, so anything you queue before that travels together.

Run it in another terminal:

npx tsx client.ts

Step 5: Promise Pipelining — the Main Event

Here's the part that REST cannot do. We want to:

  1. Authenticate with a token.
  2. Get the authenticated user's ID.
  3. Fetch that user's full profile.

With fetch, that's three sequential round trips because each step depends on the previous result. With Cap'n Web, it's one:

// client.ts (continued)
import { newHttpBatchRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
 
async function pipeline() {
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
 
  // 1. authenticate() returns a stub for the AuthedApi — not awaited.
  const session = api.authenticate("user-1");
 
  // 2. Call into that stub. userIdPromise is a future value on the server.
  const userIdPromise = session.getUserId();
 
  // 3. Feed the unresolved promise straight into the next call.
  //    Cap'n Web records the dependency instead of resolving it locally.
  const profilePromise = api.getUserProfile(userIdPromise);
 
  // One await -> one round trip for all three operations.
  const profile = await profilePromise;
  console.log(profile); // { id: 1, name: 'Amira', bio: '...' }
}
 
pipeline();

Read that again: api.getUserProfile(userIdPromise) is passed a promise, not a number. Cap'n Web sees that userIdPromise is the result of an earlier call in the same batch and rewrites the request so the server resolves getUserId() first, then feeds it into getUserProfile() — all before sending anything back. The client never sees the intermediate user ID unless it asks for it.

This is the difference between three HTTP requests and one. On a 200 ms mobile connection, that's 600 ms versus 200 ms — a tangible win for users on slower networks.

Step 6: The Magic .map() Method

What if you authenticate, get a list of friend IDs, and want each friend's profile? Naively that's one call for the list, then N calls for the profiles. Cap'n Web's .map() runs the transformation on the server, so everything still fits in one round trip:

// client.ts (continued)
async function fanOut() {
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
 
  const session = api.authenticate("user-1");
 
  // getFriendIds() returns an RpcPromise<number[]>.
  // .map() schedules a server-side transform over each element.
  const friendProfiles = session
    .getFriendIds()
    .map((friendId) => api.getUserProfile(friendId));
 
  // Still ONE round trip: auth -> friend ids -> N profile lookups.
  const profiles = await friendProfiles;
  console.log(profiles.map((p) => p.name)); // ['Youssef', 'Lina']
}
 
fanOut();

The callback passed to .map() is special: it must be synchronous, its only meaningful side effects are further RPC calls, and the friendId it receives is itself a remote RpcPromise — you cannot, say, run friendId + 1 arithmetic on it directly. Think of .map() as "describe a server-side loop," not "run a JavaScript loop." Within those rules it eliminates the classic N+1 query waterfall entirely.

Step 7: Passing Callbacks to the Server

Because functions are first-class in Cap'n Web, you can pass a client function as an argument and the server can call it remotely. This is the foundation for live subscriptions and progress callbacks. Add a method to the server:

// server.ts — add to PublicServer
notifyEach(
  ids: number[],
  onItem: (name: string) => void,
): number {
  for (const id of ids) {
    const profile = USERS[id];
    if (profile) onItem(profile.name); // calls back into the client
  }
  return ids.length;
}

Then on the client, pass a plain function. Use a WebSocket session here, since callbacks need a bidirectional, long-lived connection:

// client.ts (continued)
import { newWebSocketRpcSession } from "capnweb";
import type { PublicApi } from "./api.ts";
 
async function withCallback() {
  using api = newWebSocketRpcSession<PublicApi & {
    notifyEach(ids: number[], onItem: (name: string) => void): number;
  }>("ws://localhost:8080");
 
  const count = await api.notifyEach([1, 2, 3], (name) => {
    // This runs on the CLIENT, invoked remotely by the server.
    console.log("Server reported:", name);
  });
 
  console.log("Total notified:", count);
}
 
withCallback();

The function is passed by reference as a stub. The server holds a handle to it and invokes it across the wire. This same mechanism lets a server push updates to a connected client — a clean alternative to wiring up a separate event channel.

Step 8: Error Handling and Disposal

Errors thrown on the server are serialized and re-thrown on the client, so ordinary try/catch works:

async function handleErrors() {
  using api = newHttpBatchRpcSession<PublicApi>("http://localhost:8080/api");
  try {
    await api.authenticate("bad-token"); // server throws "Invalid token"
  } catch (err) {
    console.error("Auth failed:", (err as Error).message);
  }
}

For long-lived WebSocket sessions, listen for the connection breaking so you can reconnect or surface an offline state:

const api = newWebSocketRpcSession<PublicApi>("ws://localhost:8080");
api.onRpcBroken((error) => {
  console.error("Connection lost:", error);
  // All further calls on this stub will reject.
});

When you create a stub without using, dispose it manually so the server can release any objects tied to it:

const api = newWebSocketRpcSession<PublicApi>("ws://localhost:8080");
await api.getUserProfile(1);
api[Symbol.dispose](); // closes the connection

If you need a remote object to outlive the call it came from, duplicate it with .dup() before passing the original somewhere that might dispose it.

Step 9: Deploying to Cloudflare Workers

Cap'n Web was built by Cloudflare, so Workers is its most natural home. The same RpcTarget server class works unchanged — only the entry point differs:

// worker.ts
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
import type { PublicApi } from "./api.ts";
 
class PublicServer extends RpcTarget implements PublicApi {
  // ...same implementation as before...
}
 
export default {
  fetch(request: Request) {
    const url = new URL(request.url);
    if (url.pathname === "/api") {
      return newWorkersRpcResponse(request, new PublicServer());
    }
    return new Response("Not found", { status: 404 });
  },
};

Deploy with wrangler deploy, point your client URL at the Worker, and you have a globally distributed RPC endpoint. Bun and Deno are supported too, via newBunWebSocketRpcHandler and newHttpBatchRpcResponse respectively.

Testing Your Implementation

Run the server in one terminal and the client in another. You should verify each behavior independently:

  • Batching: Log a counter in nodeHttpBatchRpcResponse or watch the network — two getUserProfile calls produce one request.
  • Pipelining: The three-step auth → id → profile flow returns a profile with a single await. Add a console.log in each server method to confirm they all run within one request.
  • .map(): fanOut() prints ['Youssef', 'Lina'] with no client-side loop over the network.
  • Callbacks: withCallback() prints "Server reported:" three times, proving the server invoked your client function.

If a call hangs, confirm the server is listening on port 8080 and that your client URL uses http:// for batch and ws:// for WebSocket.

Troubleshooting

using is a syntax error. Your TypeScript target is below ES2022, or your runtime is old. Bump target to ES2022 and use Node.js 20+.

Cannot read properties of undefined on a method call. Only prototype methods are exposed. If you defined a method as an instance arrow-function field (method = () => {}), move it to a regular class method so it lives on the prototype.

The client never gets a response over HTTP batch. The batch only flushes when you await. Make sure something awaits the queued promises.

A Map, Set, or RegExp argument fails to serialize. These types are not pass-by-value in Cap'n Web. Convert to plain objects/arrays, or wrap behavior in an RpcTarget.

State leaks between users. You stored session data on a module global instead of an instance. Create a new PublicServer() per connection and keep per-user state on RpcTarget instances.

Cap'n Web vs tRPC and oRPC

If you've used tRPC or oRPC, here's the mental model shift. tRPC gives you typed procedures over HTTP, batched but still flat — every call is independent. Cap'n Web gives you typed objects whose methods return more objects, and it pipelines dependent calls into one round trip. tRPC integrates tightly with React Query and the Next.js ecosystem today; Cap'n Web is leaner, transport-agnostic, and shines when call chains are deep or latency dominates. They are not mutually exclusive — you might keep tRPC for your Next.js data layer and reach for Cap'n Web on a latency-critical, object-graph-heavy path.

Next Steps

  • Add real authentication by verifying a JWT inside authenticate() and storing the verified claims on the AuthedSession instance.
  • Explore bidirectional sessions over WebSocket where the server holds client callback stubs to push live updates.
  • Combine Cap'n Web with Cloudflare Workers and Durable Objects for stateful, globally distributed RPC.
  • Read the protocol specification to understand how the export/import tables and pipelining are wired on the wire.

Conclusion

Cap'n Web rethinks the request/response model around objects and futures instead of flat endpoints. By treating remote results as promises you can pass forward, it folds entire call chains into a single round trip — eliminating both the latency waterfall and the N+1 query problem. With end-to-end TypeScript types from a plain interface, no code generation, and a sub-10 kB footprint, it's an unusually clean way to talk between browser and server.

You've built a complete authenticated API, consumed it three ways — batch, pipeline, and .map() fan-out — passed a callback the server invoked remotely, and seen how the same server class deploys to Cloudflare Workers. The next time you catch yourself chaining fetch calls, remember you can describe the whole chain once and let the server run it.

For more on type-safe APIs and edge deployment, explore our guides on tRPC with the App Router and Cloudflare Workers with Hono and D1.