A reference starter that runs TanStack Start on Cloudflare Workers with all server communication routed over a single persistent WebSocket connection using capnweb RPC.
Both TanStack Start server functions (createServerFn) and custom worker RPC methods share the same WebSocket, with automatic fallback to HTTP when the socket is unavailable.
graph LR
subgraph Browser
A[React App] --> B[capnweb RPC session]
B --> C[wsFetch]
end
B <-->|WebSocket /api/ws| D
subgraph Worker["Cloudflare Worker"]
D[CoreRpcRoot] --> E[TanStack Start handler]
D --> F[DemoRpc methods]
D --> G["#doRoots (DO sessions)"]
end
G <-->|"hibernating WebSocket"| H
G <-->|"hibernating WebSocket"| I
subgraph DOs["Durable Objects"]
H[SharedCounterDO]
I[SharedReactionBoardDO]
end
TanStack Start server functions normally make HTTP fetch requests to endpoints like POST /rsc/__ACTIONS_0. This starter reroutes them over WebSocket in two layers:
src/start.ts— InjectswsFetchinto TanStack Start'screateStart()via theserverFns.fetchoption (the framework's official hook for custom transport). NoglobalThis.fetchpatching is needed.server-entry.ts— TheCoreRpcRootclass exposes afetch(request): Responsemethod that forwards incoming requests totanstackHandler.fetch(), which is the standard TanStack Start SSR handler.
The result: createServerFn calls are serialized as Request objects, sent over the WebSocket as capnweb RPC calls to CoreRpcRoot.fetch(), processed by TanStack Start on the worker, and the Response comes back over the same socket. App code doesn't need to know any of this — server functions just work.
For worker-only logic that doesn't need TanStack Start's server function machinery, you can define methods directly on the RpcTarget subclass. The withDemoRpc mixin adds methods like rollDice(), banner(), and nameColors() that are callable from the client as typed async method calls:
const rpc = getRpc()
const results = await rpc.rollDice(5) // number[]
const banner = await rpc.banner('HELLO') // stringThese bypass TanStack Start entirely — capnweb handles serialization, dispatch, and return values directly.
initSocket() is called once from the root route (src/routes/__root.tsx). It:
- Opens a WebSocket to
/api/ws - Creates a capnweb RPC session on the socket
- Reconnects automatically on disconnect (1s delay)
Server functions are routed over WebSocket via wsFetch, injected into TanStack Start's createStart() as serverFns.fetch. Falls back to native HTTP when the socket isn't connected.
The /multiplexing route demonstrates how to proxy capnweb RPC connections from the browser through the worker to multiple Durable Objects, with full hibernation support. The DOs can sleep between interactions, wake on demand, and the RPC stubs the client holds continue to work seamlessly.
sequenceDiagram
participant Client as Browser
participant Worker as Cloudflare Worker
participant Counter as SharedCounterDO
participant Reactions as SharedReactionBoardDO
Client->>Worker: connectCounter("room")
Worker->>Counter: WebSocket + capnweb session
Counter-->>Worker: root stub
Worker->>Counter: root.getCounter()
Counter-->>Worker: CounterCapability stub
Worker-->>Client: proxied child stub
Client->>Worker: counter.increment()
Worker->>Counter: proxied → CounterCapability.increment()
Counter-->>Worker: { count, instanceId }
Worker-->>Client: proxied result
Note over Counter: DO hibernates after idle
Client->>Worker: counter.increment()
Worker->>Counter: wake + restore session
Counter-->>Worker: { count, newInstanceId }
Worker-->>Client: proxied result (ID changed = hibernation worked!)
This follows the same approach used in the capnweb hibernation tests, where a client holds a root stub and acquires child capability stubs from it. The difference here is that the worker is the client to the DO, and the browser client receives the child stubs proxied through the worker.
1. DO exposes a root with child capabilities
The DO's RPC root doesn't expose the API directly. Instead, it has a method that returns a child RpcTarget:
class CounterRpcRoot extends RpcTarget {
getCounter() {
return new CounterCapability(this.host)
}
getInstanceId() {
return this.host.instanceId
}
}The DO uses __experimental_newHibernatableWebSocketRpcSession so it can hibernate while WebSocket connections remain open.
2. Worker holds root stubs, passes child stubs to the client
The worker opens a WebSocket to each DO and creates a capnweb session. It keeps the root stub alive (preventing session shutdown) and calls methods on it to get child capability stubs:
class CoreRpcRoot extends RpcTarget {
#doRoots = new Map<string, RpcStub<any>>()
async connectCounter(roomId: string) {
const root = await this.#getDoRoot<CounterRootApi>(
workerEnv!.SHARED_COUNTER, roomId, `counter:${roomId}`,
)
return root.getCounter() // child stub, proxied to client
}
}capnweb automatically proxies the child stub across the client-worker session. When the browser client calls counter.increment(), the call flows: browser → worker → DO.
3. Browser client uses stubs transparently
const counter = await rpc.connectCounter('demo-room')
await counter.subscribe(new Handler())
const result = await counter.increment()The client doesn't know or care that calls are being proxied through the worker to a DO.
- Hibernation works: The DO can sleep between interactions. When it wakes, the constructor re-runs (generating a new
instanceId), but the capnweb session restores from the WebSocket attachment and held stubs continue to work. - No manual bridging: Unlike raw WebSocket approaches where you'd manually frame messages and translate between protocols, capnweb handles serialization, dispatch, and cross-session proxying automatically.
- Multiplexed: A single browser WebSocket to the worker fans out to multiple DO WebSocket connections. Each DO is independent and can hibernate on its own schedule.
- Bidirectional: DOs can push to clients via callbacks (e.g.,
subscribe(callback)for real-time counter updates and reactions).
Each DO generates a crypto.randomUUID() as instanceId in its constructor. The UI shows this ID — when it changes between interactions, you know the DO hibernated and woke up. The /multiplexing page displays this with a confetti celebration when a wake-up is detected.
Each demo page showcases both transport types side by side, with transport badges showing whether each call went over WebSocket or HTTP and the round-trip latency.
| Route | Server Function | capnweb RPC |
|---|---|---|
/dice |
Compute roll statistics on the server | Roll dice on the worker |
/ascii |
Fetch animal facts | Render ASCII art banners |
/colors |
Analyze color properties (hue, saturation, lightness) | Generate creative color names |
/multiplexing |
— | Shared counter + reaction board via two Durable Objects with hibernation |
server-entry.ts Cloudflare Worker entry; WebSocket upgrade + RPC root
src/
start.ts TanStack Start config; injects wsFetch as server function transport
ws.ts WebSocket singleton; capnweb RPC session; wsFetch export
transport-log.ts Observable log of transport events (ws vs http) for UI
demo-rpc.ts Demo RPC method definitions + DemoApi type
router.tsx TanStack Router config
routes/
__root.tsx Root layout; calls initSocket()
index.tsx Home page
dice.tsx Dice roller demo
ascii.tsx ASCII art zoo demo
colors.tsx Color palette demo
multiplexing.tsx DO multiplexing demo (counter + reaction board)
do/
shared-counter.ts SharedCounterDO with hibernatable capnweb RPC
shared-guestbook.ts SharedReactionBoardDO with hibernatable capnweb RPC
components/
Header.tsx Nav header with WebSocket status indicator
TransportBadge.tsx Badge showing transport type + latency
TransportIndicator.tsx Aggregate WebSocket vs HTTP fetch counts
pnpm install
pnpm devThe dev server runs on http://localhost:3000. The WebSocket connects automatically.
pnpm deployThis runs vite build then wrangler deploy using the config in wrangler.jsonc.
- Define the client-side type in a shared interface:
export interface MyApi {
myMethod(arg: string): string
}-
Implement the method on an
RpcTargetsubclass or mixin (seesrc/demo-rpc.tsfor the pattern) -
Add the interface to
ServerApiinsrc/ws.ts:
export interface ServerApi extends DemoApi, MyApi {
fetch(request: Request): Response
}The method is now callable from the client via getRpc().myMethod('hello').