Skip to content

messages

Eugene Lazutkin edited this page Aug 8, 2026 · 4 revisions

Messages

The control plane: pages and worker speak a small versioned contract over postMessage, with BroadcastChannel('io') for fan-out. Constants ride in double-meh-sw/contract.js (MESSAGES, CONTRACT_VERSION, CAPABILITIES).

const post = m => navigator.serviceWorker.ready.then(r => r.active.postMessage(m));

post({type: 'io:hello', library: 'double-meh'}); // announce a library — claims bundling
post({type: 'io:invalidate', pattern: '/api/users/'}); // evict the shared tier, all tabs notified
post({type: 'io:version'}); // → {current}
post({type: 'io:upgrade'}); // → skipWaiting()

The messages

  • io:hello — capability handshake. The reply carries the contract version, the app version, and the worker's capabilities. A library claim marks that client: client-wins — its requests are never SW-bundled; the page library owns its bundling (it has named bundles and explicit flush; the worker only sees anonymous fetches).

  • io:invalidate {pattern} — evicts from the cache tier and broadcasts io:invalidated {pattern, evicted} on the io channel so every tab can drop in-page mirrors.

  • io:version / io:upgrade — the version-upgrade flow: query the active worker's version; trigger skipWaiting() when the page decides to move.

  • io:fetch {id, url, method?, headers?, stream?} + a MessageChannel port — the message transport: the worker performs the fetch (kept alive by waitUntil, so it completes even if the page navigates away), seeds GETs into the cache tier, and replies on the port with {id, status, statusText, headers, body} — the body transferred, not copied. This is the navigation-surviving prefetch, and it works even on pages the worker doesn't control yet (messaging needs no controller; interception does).

    url must be absolute. A service worker's base is its own script URL, not the page's, so a relative URL resolves here against the script. The two coincide whenever the page and the script sit in the same directory — which is why this misbehaves only for pages outside it: /deep/page.html with a root /sw.js fetches /api/x where the page meant /deep/api/x, silently and with a 200. Only the page knows its own base, so the page half resolves before posting; the worker relies on that and has no way to repair it.

    The body is an ArrayBuffer by default, and the tier is seeded before the reply is posted — which is what lets a prefetch survive an immediate navigation. Ask for stream: true and, where the platform can transfer a ReadableStream (announced as the stream capability in the io:hello reply), the reply instead carries the live body plus stream: true; the tier then drains its own tee branch behind the client. A worker that cannot transfer streams silently answers with the buffer, so asking is always safe. Negotiated per request, never automatic: v1 clients that don't ask see byte-identical behaviour, which is why this did not move the contract version.

Per-request metadata travels as x-io-* headers (the data plane) and is always stripped before anything reaches the network; the message contract is the ambient control plane. Two planes, no races.

See also: The assembly, Bundle window.

Clone this wiki locally