// The two strategies being compared, and the fake network they run over.
// No DOM in this file -- main.js does the wiring, so this stays readable as
// an answer to "what is the actual difference between the two approaches?".
import { newHttpBatchRpcSession } from './vendor/capnweb.js';
export const RPC_URL = new URL('/rpc', location.href).href;
const JITTER_MS = 40;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Run `fn` with `fetch` wrapped so each RPC POST is counted and padded with
* simulated uplink and downlink latency. Restores the real fetch afterwards so
* a failed run cannot leave the page in a patched state.
*
* Latency is simulated on this side on purpose: the server does exactly the
* same work in both columns, so the difference you see is round trips and
* nothing else.
*/
async function withSimulatedNetwork(rttMs, fn) {
const realFetch = globalThis.fetch.bind(globalThis);
const latency = () => rttMs + Math.random() * JITTER_MS;
let posts = 0;
globalThis.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input);
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
if (url.startsWith(RPC_URL) && method === 'POST') {
posts++;
await sleep(latency());
const response = await realFetch(input, init);
await sleep(latency());
return response;
}
return realFetch(input, init);
};
const started = performance.now();
try {
const value = await fn();
return { value, posts, ms: performance.now() - started };
} finally {
globalThis.fetch = realFetch;
}
}
// One session. `user` is never awaited before being used, so `user.id` is sent
// as a pipelined reference rather than a resolved value.
export const pipelined = (rttMs) =>
withSimulatedNetwork(rttMs, async () => {
const api = newHttpBatchRpcSession(RPC_URL);
const user = api.authenticate('cookie-123');
const profile = api.getUserProfile(user.id);
const notifications = api.getNotifications(user.id);
const [u, p, n] = await Promise.all([user, profile, notifications]);
return { user: u, profile: p, notifications: n };
});
// Three sessions, each awaited before the next can be built.
export const sequential = (rttMs) =>
withSimulatedNetwork(rttMs, async () => {
const user = await newHttpBatchRpcSession(RPC_URL).authenticate('cookie-123');
const profile = await newHttpBatchRpcSession(RPC_URL).getUserProfile(user.id);
const notifications = await newHttpBatchRpcSession(RPC_URL).getNotifications(user.id);
return { user, profile, notifications };
});// DOM wiring for the comparison. The RPC is all in demo.js.
import { pipelined, sequential } from './demo.js';
const $ = (id) => document.getElementById(id);
const rtt = () => Number($('rtt').value);
function reset() {
for (const id of ['pPosts', 'pTime', 'sPosts', 'sTime']) $(id).textContent = '\u2026';
$('pOut').textContent = $('sOut').textContent = 'Not run yet.';
$('pBar').style.width = $('sBar').style.width = '0';
$('verdict').hidden = true;
$('verdict').classList.remove('error');
}
async function run() {
$('run').disabled = true;
$('run').textContent = 'Running\u2026';
reset();
try {
const p = await pipelined(rtt());
$('pPosts').textContent = p.posts;
$('pTime').textContent = `${Math.round(p.ms)} ms`;
$('pOut').textContent = JSON.stringify(p.value, null, 2);
const s = await sequential(rtt());
$('sPosts').textContent = s.posts;
$('sTime').textContent = `${Math.round(s.ms)} ms`;
$('sOut').textContent = JSON.stringify(s.value, null, 2);
const worst = Math.max(p.ms, s.ms) || 1;
$('pBar').style.width = `${(p.ms / worst) * 100}%`;
$('sBar').style.width = `${(s.ms / worst) * 100}%`;
const saved = Math.round(s.ms - p.ms);
const times = (s.ms / p.ms).toFixed(2);
$('verdict').innerHTML =
`<strong>${p.posts} round trip vs ${s.posts}.</strong> Pipelining finished ` +
`${saved} ms sooner (${times}× faster), returning identical data.`;
$('verdict').hidden = false;
} catch (err) {
$('verdict').classList.add('error');
$('verdict').textContent = `Failed: ${err?.message ?? err}`;
$('verdict').hidden = false;
} finally {
$('run').disabled = false;
$('run').textContent = 'Run both';
}
}
/** Keeps the readout and the slider's painted fill in step with the value. */
function syncRtt() {
const el = $('rtt');
const min = Number(el.min);
const fraction = (Number(el.value) - min) / (Number(el.max) - min);
el.style.setProperty('--pct', `${fraction * 100}%`);
$('rttValue').textContent = `${el.value} ms`;
}
$('rtt').addEventListener('input', syncRtt);
syncRtt();
$('run').addEventListener('click', run);
$('reset').addEventListener('click', reset);
run();// The RPC API shared by every entry point in this example: the Node server
// (`server-node.mjs`) and the Cloudflare Worker (`worker.js`).
//
// `capnweb` is a bare specifier here rather than a relative path into `dist/`.
// Under Node it resolves through the repo's own workspace self-link; under
// Workers it is mapped to the workerd build by the `alias` block in
// `wrangler.jsonc`. Either way there is exactly one copy of the library, which
// matters because `RpcTarget` identity is checked at the session boundary.
import { RpcTarget } from 'capnweb';
const sleep = (ms) => (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve());
const USERS = new Map([
['cookie-123', { id: 'u_1', name: 'Ada Lovelace' }],
['cookie-456', { id: 'u_2', name: 'Alan Turing' }],
]);
const PROFILES = new Map([
['u_1', { id: 'u_1', bio: 'Mathematician & first programmer' }],
['u_2', { id: 'u_2', bio: 'Mathematician & computer science pioneer' }],
]);
const NOTIFICATIONS = new Map([
['u_1', ["Welcome to Cap'n Web!", 'You have 2 new followers']],
['u_2', ['New feature: pipelining!', 'Security tips for your account']],
]);
/** Per-method artificial latency, in milliseconds. */
export const DEFAULT_DELAYS = { auth: 80, profile: 120, notifications: 120 };
/**
* Pull delay overrides out of an environment-shaped record. Works for both
* `process.env` (strings) and Workers `env` (numbers from `vars`).
*/
export function delaysFrom(source = {}) {
const num = (value, fallback) => {
const n = Number(value);
return Number.isFinite(n) && n >= 0 ? n : fallback;
};
return {
auth: num(source.DELAY_AUTH_MS, DEFAULT_DELAYS.auth),
profile: num(source.DELAY_PROFILE_MS, DEFAULT_DELAYS.profile),
notifications: num(source.DELAY_NOTIFS_MS, DEFAULT_DELAYS.notifications),
};
}
export class Api extends RpcTarget {
#delays;
constructor(delays = DEFAULT_DELAYS) {
super();
this.#delays = { ...DEFAULT_DELAYS, ...delays };
}
// Simulate authentication from a session cookie/token.
async authenticate(sessionToken) {
await sleep(this.#delays.auth);
const user = USERS.get(sessionToken);
if (!user) throw new Error('Invalid session');
return user; // { id, name }
}
async getUserProfile(userId) {
await sleep(this.#delays.profile);
const profile = PROFILES.get(userId);
if (!profile) throw new Error('No such user');
return profile; // { id, bio }
}
async getNotifications(userId) {
await sleep(this.#delays.notifications);
return NOTIFICATIONS.get(userId) ?? [];
}
}// Cloudflare Worker serving the same API as `server-node.mjs`, plus the
// browser demo in `public/`.
//
// Static assets are served ahead of this Worker by the `assets` config, so
// `fetch` only ever sees `/rpc` (assets do not handle POST) and unknown paths.
//
// Note there is no artificial network latency here. The round-trip cost is
// simulated in the browser instead, so the page can expose it as a slider
// without a redeploy -- exactly what `client.mjs` does for the CLI.
import { newWorkersRpcResponse } from 'capnweb';
import { Api, delaysFrom } from './api.mjs';
/** The demo endpoint is public, so allow it to be called from anywhere. */
function corsHeaders(request) {
const origin = request.headers.get('Origin');
if (!origin) return null;
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers':
request.headers.get('Access-Control-Request-Headers') ?? 'Content-Type',
'Access-Control-Max-Age': '86400',
Vary: 'Origin',
};
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname !== '/rpc') {
return new Response('Not found', { status: 404 });
}
const cors = corsHeaders(request);
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors ?? {} });
}
if (request.method !== 'POST') {
return new Response('Method not allowed', {
status: 405,
headers: { Allow: 'POST, OPTIONS' },
});
}
const response = await newWorkersRpcResponse(request, new Api(delaysFrom(env)));
if (!cors) return response;
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(cors)) headers.set(key, value);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
},
};// Minimal Node HTTP server exposing an RPC endpoint over HTTP batching.
//
// Usage:
// 1) From repo root: npm run build
// 2) Start: node examples/batch-pipelining/server-node.mjs
// 3) Client: node examples/batch-pipelining/client.mjs
//
// The same API is served from a Cloudflare Worker in `worker.js`; both share
// the `Api` class in `api.mjs`.
import http from 'node:http';
import { nodeHttpBatchRpcResponse } from 'capnweb';
import { Api, delaysFrom } from './api.mjs';
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
const delays = delaysFrom(process.env);
const server = http.createServer(async (req, res) => {
// Only handle POST /rpc as a batch endpoint.
if (req.method !== 'POST' || req.url !== '/rpc') {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('Not Found');
return;
}
try {
await nodeHttpBatchRpcResponse(req, res, new Api(delays));
} catch (err) {
res.writeHead(500, { 'content-type': 'text/plain' });
res.end(String(err?.stack || err));
}
});
server.listen(PORT, () => {
console.log(`RPC server listening on http://localhost:${PORT}/rpc`);
});// Client demonstrating:
// - Batching + pipelining: multiple dependent calls, one round trip.
// - Non-batched sequential calls: multiple round trips.
//
// Usage (separate terminal from server):
// node examples/batch-pipelining/client.mjs
import { performance } from 'node:perf_hooks';
import { newHttpBatchRpcSession } from 'capnweb';
// Mirror of the server API shape (for reference only).
// authenticate(sessionToken) -> { id, name }
// getUserProfile(userId) -> { id, bio }
// getNotifications(userId) -> string[]
const RPC_URL = process.env.RPC_URL || 'http://localhost:3000/rpc';
const SIMULATED_RTT_MS = Number(process.env.SIMULATED_RTT_MS ?? 120); // per-direction
const SIMULATED_RTT_JITTER_MS = Number(process.env.SIMULATED_RTT_JITTER_MS ?? 40);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const jittered = () => SIMULATED_RTT_MS + (SIMULATED_RTT_JITTER_MS ? Math.random() * SIMULATED_RTT_JITTER_MS : 0);
// Wrap fetch to count RPC POSTs for clear logging.
const originalFetch = globalThis.fetch;
let fetchCount = 0;
globalThis.fetch = async (input, init) => {
const method = init?.method || (input instanceof Request ? input.method : 'GET');
const url = input instanceof Request ? input.url : String(input);
if (url.startsWith(RPC_URL) && method === 'POST') {
fetchCount++;
// Simulate uplink and downlink latency for each RPC POST.
await sleep(jittered());
const resp = await originalFetch(input, init);
await sleep(jittered());
return resp;
}
return originalFetch(input, init);
};
async function runPipelined() {
fetchCount = 0;
const t0 = performance.now();
const api = newHttpBatchRpcSession(RPC_URL);
const user = api.authenticate('cookie-123');
const profile = api.getUserProfile(user.id);
const notifications = api.getNotifications(user.id);
const [u, p, n] = await Promise.all([user, profile, notifications]);
const t1 = performance.now();
return { u, p, n, ms: t1 - t0, posts: fetchCount };
}
async function runSequential() {
fetchCount = 0;
const t0 = performance.now();
// 1) Authenticate (1 round trip)
const api1 = newHttpBatchRpcSession(RPC_URL);
const u = await api1.authenticate('cookie-123');
// 2) Fetch profile (2nd round trip)
const api2 = newHttpBatchRpcSession(RPC_URL);
const p = await api2.getUserProfile(u.id);
// 3) Fetch notifications (3rd round trip)
const api3 = newHttpBatchRpcSession(RPC_URL);
const n = await api3.getNotifications(u.id);
const t1 = performance.now();
return { u, p, n, ms: t1 - t0, posts: fetchCount };
}
async function main() {
console.log(`Simulated network RTT (each direction): ~${SIMULATED_RTT_MS}ms ±${SIMULATED_RTT_JITTER_MS}ms`);
console.log('--- Running pipelined (batched, single round trip) ---');
const pipelined = await runPipelined();
console.log(`HTTP POSTs: ${pipelined.posts}`);
console.log(`Time: ${pipelined.ms.toFixed(2)} ms`);
console.log('Authenticated user:', pipelined.u);
console.log('Profile:', pipelined.p);
console.log('Notifications:', pipelined.n);
console.log('\n--- Running sequential (non-batched, multiple round trips) ---');
const sequential = await runSequential();
console.log(`HTTP POSTs: ${sequential.posts}`);
console.log(`Time: ${sequential.ms.toFixed(2)} ms`);
console.log('Authenticated user:', sequential.u);
console.log('Profile:', sequential.p);
console.log('Notifications:', sequential.n);
console.log('\nSummary:');
console.log(`Pipelined: ${pipelined.posts} POST, ${pipelined.ms.toFixed(2)} ms`);
console.log(`Sequential: ${sequential.posts} POSTs, ${sequential.ms.toFixed(2)} ms`);
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});Authenticate a user, then fetch that user’s profile and notifications, both of which need the user ID that the first call returns. Pipelined, all three travel in one HTTP request. Written the ordinary way, they take three.
Drag the latency slider and run it again. The server does identical work in both columns; the only difference is how many times the browser has to cross the network.
What makes it one round trip
The three calls are issued against a single session, and the second and third are built from a promise that has not resolved yet:
const api = newHttpBatchRpcSession(RPC_URL);
const user = api.authenticate('cookie-123'); // not awaited
const profile = api.getUserProfile(user.id); // uses user.id anyway
const notifications = api.getNotifications(user.id);
const [u, p, n] = await Promise.all([user, profile, notifications]);user.id is not a string here. It is a reference to a field of a result the server has not
produced yet. Cap’n Web sends that reference as part of the same batch, and the server substitutes
the real value when it gets there. Nothing has to come back to the client in between.
The sequential version awaits each call before building the next, so the client cannot know
user.id until a full round trip has completed:
const user = await newHttpBatchRpcSession(RPC_URL).authenticate('cookie-123');
const profile = await newHttpBatchRpcSession(RPC_URL).getUserProfile(user.id);
const notifications = await newHttpBatchRpcSession(RPC_URL).getNotifications(user.id);Same data, same server work, three times the network cost, and that cost grows with the length of the chain, which is the part that hurts on a slow connection.
Where the latency comes from
Two separate knobs, deliberately kept apart:
- Server-side work: per-method delays set by
DELAY_AUTH_MS,DELAY_PROFILE_MSandDELAY_NOTIFS_MS. Identical in both modes, so it is not what the demo measures. - Network round trips: simulated in the browser by the slider. This is the part pipelining removes.
Keeping the round-trip cost on the client means the deployed Worker adds no artificial delay, and the page can change it without a redeploy.
How this page runs
The demo above is the example’s unmodified Worker and browser client, bundled together into one
page. A small shim replaces fetch for the /rpc path and hands the request straight to the
Worker’s fetch handler:
globalThis.fetch = async (input, init) => {
const request = new Request(input, init);
if (new URL(request.url).pathname === '/rpc') {
return await worker.fetch(request, ENV, ctx);
}
return upstream(input, init);
};Everything above that line is untouched: the same session setup, the same batch encoding, the same
newWorkersRpcResponse on the other end. Only the transport hop is gone, which is why the round-trip
counts mean what they say and why these docs deploy as static files.
Run it yourself
npm run build # the examples resolve capnweb to dist/
npx wrangler dev --cwd examples/batch-pipelining --ip 127.0.0.1 --port 8788The terminal client runs the same comparison against a server in a separate process:
node examples/batch-pipelining/server-node.mjs # in one shell
node examples/batch-pipelining/client.mjs # in anotherNext
- RpcPromise & pipelining: how the promise references work.
- The pipelining tour: a step-by-step walkthrough, including
.map(). - HTTP batch transport: what this example is running on.