Skip to content
Eugene Lazutkin edited this page Jul 10, 2026 · 2 revisions

Bundle transport

Batches eligible GETs into one PUT to a bundler endpoint and transparently unbundles the combined response — each request keeps its own promise, cache entry, ETag/304 revalidation, and error. The win is compression locality (many small responses share one compression window; measured ~40–48% fewer bytes on bursts of small JSON) plus cross-origin aggregation no HTTP/2 connection can do. Off by default: bundling needs a server counterpart.

import io from 'double-meh';

io.bundle.url = '/bundle'; // the bundler endpoint (a separate server component)

// join the default window (~20ms): a burst rides one PUT
const [user, config] = await Promise.all([
  io.get('/api/me', null, {bundle: true}),
  io.get('/api/config', null, {bundle: true})
]);

Enable it wholesale with a predicate instead of per request: io.bundle.theDefault = options => … (the per-request bundle: option always wins).

Named bundles — no window, no waiting

A string names a bundle that collects until you flush it — the synchronous-burst case pays zero window delay:

const parts = [
  io.get('/api/orders', null, {bundle: 'dash'}),
  io.get('/api/alerts', null, {bundle: 'dash'})
];
await io.bundle.flush('dash'); // sends now; resolves after the response is unpacked

A forgotten flush is not a hang: named bundles auto-flush after io.bundle.maxWait (default 500ms). Manual lists in one shot: io.bundle.submit(['/api/a', '/api/b']) returns the array of promises.

Configuration

Knob Default Meaning
url '' The default bundler endpoint; bundling is inert until set.
waitTime 20 Auto-flush window of the default bundle, ms.
maxSize 20 A window reaching this flushes immediately; larger flushes chunk.
minSize 2 Below this a flush sends its requests individually — a degenerate bundle is not worth the round-trip.
maxWait 500 Safety auto-flush for named bundles, ms.
writeThrough false Also write parts into the Cache API (a cache name, or true for "io-shared" — the SW integration shared tier) so a service worker can serve them to unadorned fetch().

Multiple bundlers select by URL: io.bundle.register({url: '/api-bundle', match: '/api/'})match is a prefix, RegExp, or predicate; first match wins, io.bundle.url is the catch-all.

What is (and isn't) bundled

GET only; streaming (io.stream, records, SSE) and bust requests never bundle; a custom transport opts out. Identical in-flight GETs collapse into a single part (track-key dedup), and cache hits never enter a window at all — only true misses ride the bundle. Per-request bundle: false exempts a request (the bundle's own PUT uses it internally).

Everything composes: auth inspectors enrich the bundle PUT like any request, io.inFlight/events count logical requests, a failed PUT rejects every waiter with a FailedIO (cause attached), a bundler-side per-part failure (synthetic: true) becomes that caller's FailedIO, and a genuine upstream 4xx/5xx part throws the usual BadStatus with the parsed body.

Bundles from any endpoint

The unbundler keys on the response MIME type (application/vnd.double-meh.bundle+json), not on the endpoint — any response shaped as a bundle is unpacked: parts with waiters resolve them; unclaimed parts are treated as prefetches and adopt-seeded into the cache for future requests. Pair with io.bundle.fly(['/api/x']) to hold a promise for a part you expect some endpoint to push.

The wire format (v1)

Request application/vnd.double-meh.bundle-request+json, response application/vnd.double-meh.bundle+json; envelopes {v: 1, parts: [...]} correlated by client-assigned id, parts echoing their url. Conditional headers ride per part (so 304 revalidation works through the bundler); auth and cookies stay on the outer request for the bundler to propagate. The exhaustive spec, with the server-side obligations (isUrlAcceptable allow-list, header handling, synthetic failures), lives in the design record; the reference implementation is the bundle route of the test-server fixtures plugin.

See also

Clone this wiki locally