vs Cap'n Web ("It just works")
📘 Doc-testing – Why do these examples look like tests?
This documentation uses testable code examples to ensure accuracy and reliability:
- Guaranteed accuracy: All examples are real, working code that runs against the actual package(s)
- Guaranteed latest comparisons: Further, our release script won't allow us to release a new version of Lumenize, without prompting us to update any doc-tested comparison package (e.g. Cap'n Web)
- Always up-to-date: When a package changes, the tests fail and the docs must be updated
- Copy-paste confidence: What you see is what works - no outdated or broken examples
- Real-world patterns: Tests show complete, runnable scenarios, not just snippets
Ignore the test boilerplate (it(), describe(), etc.) - focus on the code inside.
Cap'n Web's documentation states that Workers RPC interoperability "it just works" This living documentation explores that claim and highlights what we've learned about its limitations.
Our experience: Cap'n Web's syntax is quite elegant—you can magically return an RpcTarget instance or Durable Object stub, and it's conceptually consistent. Lumenize RPC is more explicit but similarly concise. We'd give the win to Cap'n Web's elegance, except that once you get past the, "Wow! That's cool!" we discovered some things that "just don't work" as expected.
-
Limited type support: Cap'n Web doesn't support many types that Workers RPC handles seamlessly (Map, Set, RegExp, ArrayBuffer, circular references, etc.). See the type support comparison.
-
No hibernating WebSocket support: Cap'n Web uses
server.accept()instead ofctx.acceptWebSocket(), meaning Durable Objects can't maintain connections through hibernations, which makes sense because... -
Workarounds may be required for callbacks: While passing a function callback over RPC works (which is impressive!), it requires keeping the DO in memory because you can't serialize a callback to restore from storage after it wakes up. We used
setTimeout(). -
Potentially confusing magic: The syntax is so elegant it might seem "too good to be true"—even our AI coding LLM initially refused to try patterns that actually work, assuming they wouldn't!
Important: We might be missing something fundamental. If there are different patterns that work better and/or if Cloudflare adds support for more types, we'll quickly update this document. Cloudflare has noted that some type support "may be added in the future." For now, based on our testing, the claim "it just works" comes with significant caveats.
src/index.ts
Normally, we start off these doc-tests with the tests that show behavior, but in this case, we want you to look at the Worker, DurableObjects and RpcTargets first.
import { DurableObject, RpcTarget } from 'cloudflare:workers';
import { lumenizeRpcDO } from '@lumenize/rpc';
import { routeDORequest } from '@lumenize/utils';
import { newWorkersRpcResponse } from 'capnweb';
// =======================================================================
// Lumenize RPC - User and Room services
// =======================================================================
// Room stores messages using Map<number, string>
export class Room extends DurableObject {
addMessage(text: string): number {
const messages =
this.ctx.storage.kv.get<Map<number, string>>('messages') ??
new Map();
const id = messages.size + 1;
messages.set(id, text);
this.ctx.storage.kv.put('messages', messages);
return id;
}
getMessages(): Map<number, string> {
return (
this.ctx.storage.kv.get<Map<number, string>>('messages') ??
new Map()
);
}
}
// Empty class to hold the hibernatable WebSocket (accepted by `lumenizeRpcDO`)
class _User extends DurableObject<Env> {
// A real implementation would have methods like login, etc.
}
export const User = lumenizeRpcDO(_User);
// =======================================================================
// Cap'n Web - Clean and elegant
// =======================================================================
// Cap'n Web Room - Uses Map (will fail on getMessages())
export class CapnWebRoom extends DurableObject<Env> {
addMessage(text: string): number {
const messages =
this.ctx.storage.kv.get<Map<number, string>>('messages') ??
new Map();
const id = messages.size + 1;
messages.set(id, text);
this.ctx.storage.kv.put('messages', messages);
return id;
}
getMessages(): Map<number, string> {
return (
this.ctx.storage.kv.get<Map<number, string>>('messages') ??
new Map()
);
}
}
// =======================================================================
// Cap'n Web PlainRoom - Uses plain object instead of Map
// This version also has a join() method so we can test passing functions
// as parameters and calling them back.
// =======================================================================
export class CapnWebPlainRoom extends DurableObject<Env> {
#callbacks = new Map<string, (msg: string) => void>();
join(userName: string, onMsg: (msg: string) => void): Promise<void> {
this.#callbacks.set(userName, onMsg);
// ❌ This hack keeps the RPC connection alive so callback stub remains valid
// Maybe there is a better way? If not, this isn't "It just works"
return new Promise((resolve) => {
setTimeout(() => { resolve(); }, 5000);
});
}
addMessage(text: string): number {
const messages =
this.ctx.storage.kv.get<Record<number, string>>('messages') ??
{};
const id = Object.keys(messages).length + 1;
messages[id] = text;
this.ctx.storage.kv.put('messages', messages);
// Invoke callbacks
for (const [userName, callback] of this.#callbacks.entries()) {
callback(text);
}
return id;
}
getMessages(): Record<number, string> {
return (
this.ctx.storage.kv.get<Record<number, string>>('messages') ??
{}
);
}
}
// Cap'n Web User - RpcTarget instantiated directly in worker
export class CapnWebUser extends RpcTarget {
constructor(private env: Env) {
super();
}
// Return Workers RPC stub to Room
// (uses Map - will fail on getMessages())
getRoom(roomName: string) {
return this.env.CAPNWEB_ROOM.getByName(roomName);
}
// Return Workers RPC stub to PlainRoom
// (uses plain object - will work with setTimeout() hack
getPlainRoom(roomName: string) {
return this.env.CAPNWEB_PLAIN_ROOM.getByName(roomName);
}
}
// =======================================================================
// Worker - Route requests to appropriate service
// =======================================================================
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Route Lumenize RPC requests
const lumenizeResponse = await routeDORequest( request, env,
{ prefix: '__rpc' });
if (lumenizeResponse) return lumenizeResponse;
// Route Cap'n Web RPC requests
// - instantiate RpcTarget directly per Cap'n Web pattern
if (url.pathname === '/capnweb') {
return newWorkersRpcResponse(request, new CapnWebUser(env));
}
// Fallback for non-RPC requests
return new Response('Not found', { status: 404 });
},
};
Some observations about the three implementations above:
Lumenize RPC:
- DOs extend
DurableObject(no special base class required) lumenizeRpcDO()wrapper handles all RPC setup, hibernating WebSockets, etc.client.env.ROOM.getByName().addMesssage()syntax not as slick but works.
Cap'n Web:
- Allows you to return DurableObject stubs (
CapnWebRoomandCapnWebPlainRoom) - Magical syntax for returing an
RpcTargetinstance (CapnWebUser) newWorkersRpcResponsehandles all Cap'n Web setup- Function callbacks require keeping RPC connections alive with
setTimeout()
Trade-offs: Lumenize RPC prioritizes making it easy to add RPC to existing DOs without refactoring, while Cap'n Web's elegant syntax of directly returning stubs comes with keep alive considerations and dangling resource risks.
Imports
import { it, expect, vi } from 'vitest';
// @ts-expect-error - cloudflare:test module types are not consistently exported
import { SELF, env } from 'cloudflare:test';
import { createRpcClient, createWebSocketTransport } from '@lumenize/rpc';
import { getWebSocketShim } from '@lumenize/utils';
import { newWebSocketRpcSession } from 'capnweb';
import { User, CapnWebUser } from '../src/index';
Version(s)
This test asserts the installed version(s) and our release script warns if we aren't using the latest version published to npm, so this living documentation should always be up to date.
import lumenizeRpcPackage from '../../../../packages/rpc/package.json';
import capnwebPackage from '../../../../node_modules/capnweb/package.json';
it('detects package versions', () => {
expect(lumenizeRpcPackage.version).toBe('0.17.0');
expect(capnwebPackage.version).toBe('0.1.0');
});
Creating Clients
function getLumenizeUserClient(instanceName: string) {
return createRpcClient<typeof User>({
transport: createWebSocketTransport('USER', instanceName,
{ WebSocketClass: getWebSocketShim(SELF.fetch.bind(SELF)) }
)
});
}
function getCapnWebUserClient() {
const url = `wss://test.com/capnweb`;
const ws = new (getWebSocketShim(SELF.fetch.bind(SELF)))(url);
return newWebSocketRpcSession<CapnWebUser>(ws);
}
// Alias for brevity
const getCapnWebClient = getCapnWebUserClient;
Service-to-Service Communication
A common pattern in chat applications: User service acts as a gateway/proxy, hopping to Room services for actual storage operations.
Hop Between DOs Using env
Lumenize RPC seamlessly hops from User to Room via client.env.ROOM:
- ✅ Client → User via Lumenize RPC → Room via Workers RPC
- ✅ Map and other StructuredClone types work seamlessly
- ✅ it "just works"
it('demonstrates Lumenize RPC service hopping', async () => {
using lumenizeClient = getLumenizeUserClient('user-lumenize');
// Client → User via Lumenize RPC → Room via Workers RPC
// Get stub for room - not as slick as Cap'n Web syntax but works
const roomStub = lumenizeClient.env.ROOM.getByName('lumenize');
// Type checking works across the hop so no red-squigly for `addMessage()`
const msgId1 = await roomStub.addMessage('Hello');
expect(msgId1).toBe(1);
const msgId2 = await roomStub.addMessage('World');
expect(msgId2).toBe(2);
const messages = await roomStub.getMessages();
expect(messages).toBeInstanceOf(Map); // ✅ Map works seamlessly
expect(messages.size).toBe(2);
expect(messages.get(1)).toBe('Hello');
expect(messages.get(2)).toBe('World');
});
Cap'n Web Type Limitations
Cap'n Web can hop from User to Room by returning Workers RPC stubs directly—a clean and elegant pattern. However, even though you're getting a Workers RPC stub, return values still go through Cap'n Web's serialization layer, which has limited type support:
- ✅ Plain objects, arrays, primitives: Work
- ❌ Map, Set, RegExp, ArrayBuffer: Fail
- ❌ Objects with cycles or aliases: Fail
- ❌ it "just doesn't work"
This means you must constrain your DO's return types to Cap'n Web-compatible types, even when using Workers RPC stubs or pre/post process.
it('demonstrates Cap\'n Web type limitations', async () => {
using capnwebClient = getCapnWebUserClient();
// ==========================================================================
// Cap'n Web - Map type fails
// ==========================================================================
// Get a stub to the Room (uses Map)
// Cap'n Web User returns a Workers RPC stub
using roomStub = capnwebClient.getRoom('room-capnweb-map');
// ✅ Client → User via Cap'n Web RPC (User is RpcTarget)
// ✅ User returns Workers RPC stub to Room DO
// ✅ Client → Room via the returned stub
const capnMsgId1 = await roomStub.addMessage('Hello');
expect(capnMsgId1).toBe(1);
const capnMsgId2 = await roomStub.addMessage('World');
expect(capnMsgId2).toBe(2);
// ❌ Map FAILS even though this is a Workers RPC stub!
// Return values go through Cap'n Web serialization
let capnwebThrew = false;
let capnwebError: Error | undefined;
try {
await roomStub.getMessages();
} catch (e) {
capnwebThrew = true;
capnwebError = e as Error;
}
expect(capnwebThrew).toBe(true);
expect(capnwebError?.message).toContain('Cannot serialize value');
// ==========================================================================
// Cap'n Web - Plain object works
// ==========================================================================
// Get a stub to PlainRoom (uses plain object instead of Map)
using plainRoomStub = capnwebClient.getPlainRoom('room-capnweb-plain');
const plainMsgId1 = await plainRoomStub.addMessage('Hello');
expect(plainMsgId1).toBe(1);
const plainMsgId2 = await plainRoomStub.addMessage('World');
expect(plainMsgId2).toBe(2);
// ✅ Plain object works because it's Cap'n Web compatible
const plainMessages = await plainRoomStub.getMessages();
expect(plainMessages[1]).toBe('Hello');
expect(plainMessages[2]).toBe('World');
});
The bottom line: Cap'n Web's elegant stub-returning syntax works beautifully when your return types are Cap'n Web-compatible (plain objects, arrays, primitives). But the moment you need Map, Set, RegExp, ArrayBuffer, or objects with cycles or aliases-types that Workers RPC handles seamlessly—you'll hit serialization errors or need workarounds.
Lumenize RPC supports all the types that Workers RPC supports (except Readable/WritableStream, which Cap'n Web also doesn't support), without requiring you to change your DO's return types or add pre/post processing.
Function Callbacks Work (With Limitations)
Cap'n Web duplicates Workers RPC's ability to pass functions as RPC parameters. When you pass a function, the recipient gets a stub that it can use to make a call a back to the sender.
Our Understanding (possibly incomplete): Based on our testing, RPC callback stubs cannot be serialized, stored, or survive Durable Object hibernation and they break when the method that they were passed into returns. This means:
- ✅ The method receiving the callback can invoke it immediately
- ✅ The method can store it in memory and invoke it later
- ❌ But, the callback goes away once the scope is exited or the callee leaves memory
This is why our join() method returns a Promise that doesn't resolve for 5
seconds - it keeps the RPC connection (and thus the callback stub) alive. Maybe
we're missing something fundamental, and if someone from Cloudflare points out
our mistake, we'll quickly update this document. But this was the only pattern
that allows callbacks to survive long enough to be useful.
Hibernating WebSockets: Imagine our surprise when we noticed that Cap'n Web
uses server.accept(),
instead of ctx.acceptWebSocket(), meaning that it's not using hibernating
WebSockets. Then again, when we thought about it, it made sense. Since callback
stubs can't be serialized, they'd be lost when the DO hibernates anyway. Even
with hibernating WebSockets, you'd still need to keep the DO instance alive and
the RPC connection open to use callbacks.
To be fair, until we release LumenizeBase, which solves all of this (supports hibernating WebSockets, no dangling resources, etc.) we aren't showing an equivalent Lumenize way to support any server to client updates. Stay tuned.
Bottom line: Function callbacks provide a syntactically elegant way for the
server to call the client, but require a setTimeout() to prevent losing the
callback. If there's a batter pattern we've missed, please let us know!
it('demonstrates function callback support', async () => {
const capnwebClient = getCapnWebClient();
// Multi-hop callback - client → User → PlainRoom
const roomMessages: string[] = [];
const roomCallback = (message: string) => {
roomMessages.push(message);
};
// Get the DO stub directly from User
const plainRoomStub = capnwebClient.getPlainRoom('room-callbacks-test');
plainRoomStub.join('Alice', roomCallback); // join accepts callback. Slick!
await plainRoomStub.addMessage('Test message direct');
await vi.waitFor(() => {
expect(roomMessages).toContain('Test message direct');
});
expect(roomMessages).toEqual(['Test message direct']);
});
Installation
npm install --save-dev vitest@3.2
npm install --save-dev @vitest/coverage-istanbul@3.2
npm install --save-dev @cloudflare/vitest-pool-workers
npm install --save-dev @lumenize/rpc
npm install --save-dev @lumenize/utils
npm install --save-dev capnweb
Configuration Files
wrangler.jsonc
{
"name": "capnweb-just-works-comparison",
"main": "src/index.ts",
"compatibility_date": "2025-09-12",
"durable_objects": {
"bindings": [
{
"name": "USER",
"class_name": "User"
},
{
"name": "ROOM",
"class_name": "Room"
},
{
"name": "CAPNWEB_ROOM",
"class_name": "CapnWebRoom"
},
{
"name": "CAPNWEB_PLAIN_ROOM",
"class_name": "CapnWebPlainRoom"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"User",
"Room",
"CapnWebRoom",
"CapnWebPlainRoom"
]
}
]
}
vitest.config.js
import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersProject({
test: {
testTimeout: 2000, // 2 second global timeout
poolOptions: {
workers: {
// Must be false to use websockets. Have each test
// reference a different DO instance to avoid state sharing.
isolatedStorage: false,
// Important! use the wrangler.jsonc in ./test
wrangler: { configPath: "./wrangler.jsonc" },
},
},
// Use `vitest --run --coverage` to get test coverage report(s)
coverage: {
provider: "istanbul", // Cannot use V8
reporter: ['text', 'json', 'html'],
include: ['**/src/**'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/*.config.ts',
'**/scratch/**'
],
},
},
});
Try it out
To run these examples:
vitest --run
To see test coverage:
vitest --run --coverage