Service Worker Messaging and the Clients API¶
A service worker has no DOM and no direct reference to the pages it controls, so every exchange between a page and its worker is an asynchronous message: postMessage() on a ServiceWorker or Client object, a pair of entangled MessagePorts, or a BroadcastChannel. The Clients API is the worker's view of those pages: it enumerates windows and workers, focuses and navigates them, opens new ones and takes control of them. Getting these mechanics right is what makes update prompts, precache progress bars, sync status indicators, token relays and cross-tab logout reliable instead of intermittently broken.
Key takeaways
- Page → worker: call
postMessage()on aServiceWorkerobject.navigator.serviceWorker.controllerisnullon the first visit and after a hard reload, so fall back to(await navigator.serviceWorker.ready).active. - Worker → page:
Client.postMessage()firesmessageonnavigator.serviceWorker, not onwindow. Those messages queue until you assignonmessage, callstartMessages(), or the document finishes parsing. - For request/response, create a fresh
MessageChannelper request, reply onevent.ports[0]and always add a timeout: the worker can be terminated mid-request and the page is never told. - Message handlers receive an
ExtendableMessageEvent; wrap async work inevent.waitUntil(). Chromium stops idle workers after 30 seconds and kills an event after 5 minutes in general (3 minutes forsync, a shorter custom timeout of about 90 seconds forpush). BroadcastChannelreaches every same-origin context with the same channel name, but it cannot transfer objects and cannot wake a stopped service worker.clients.openWindow()andWindowClient.focus()require window-interaction permission, which in practice exists only while handlingnotificationclick(Chromium also grants it forpaymentrequestandbackgroundfetchclick, one use per event).- Everything crosses the boundary through structured clone: no functions, DOM nodes or
Request/Responseobjects. TransferArrayBuffers, ports and streams instead of copying them.
How pages and service workers communicate¶
A service worker runs in its own service worker agent with its own event loop and, per the HTML Standard, its own agent cluster: it never shares memory with a page, and it is often in a different thread or process. Its lifetime is driven by events, not by the pages that use it: the browser starts it to dispatch an event and stops it when it has been idle for a while (see Lifecycle). Three consequences shape every messaging design on this page:
- All communication is asynchronous and copy-based. Data is serialized with the structured clone algorithm on send and deserialized on receipt, unless you transfer it.
- A message may have to start the worker first. Sending to a stopped worker pays the startup cost before your handler runs, and the worker's top-level script runs again from scratch.
- Anything the worker keeps in memory is ephemeral. Global variables,
MessagePorts stored in aMap, cached tokens: all vanish when the worker is stopped, without notifying anyone.
The platform gives you five channels. They differ in direction, fan-out, whether they can carry transferable objects and, critically, whether they can wake a stopped worker.
| Channel | API | Direction | Recipients | Transfer list | Wakes a stopped worker | Typical use |
|---|---|---|---|---|---|---|
| Worker handle | ServiceWorker.postMessage() | page or worker → service worker | one specific worker version | Yes | Yes | Commands, SKIP_WAITING, RPC requests |
| Client handle | Client.postMessage() | service worker → client | one window or worker | Yes | Not applicable | Targeted notifications, replies |
| Reply to sender | event.source.postMessage() | service worker → sender | the sender | Yes | Not applicable | Quick replies without lookups |
| Port pair | MessageChannel | bidirectional | the entangled port | Yes | No | Request/response, progress streams |
| Broadcast | BroadcastChannel | any → all | every other channel object with that name in the same storage partition | No | No | Cross-tab fan-out, install progress |
flowchart LR
A["Page A (controlled)"] -->|"controller.postMessage()"| SW["Service worker"]
SW -->|"event.source.postMessage()"| A
SW -->|"clients.matchAll() then postMessage()"| B["Page B"]
A <-->|"MessageChannel port pair"| SW
A -->|"BroadcastChannel 'app'"| BC(("app channel"))
BC --> B
BC --> SW Sending messages from a page to the service worker¶
Pick the right target: controller, active, waiting or installing¶
postMessage() is a method of the ServiceWorker interface, so the first decision is which ServiceWorker object to call it on. A page can reach up to four of them.
| Reference | What it points to | It is null when |
|---|---|---|
navigator.serviceWorker.controller | The active worker that controls this document | First visit before clients.claim(), after a hard reload, the page is outside every scope, or no registration exists |
registration.active | The registration's active worker, whether or not it controls this page | No worker has activated yet |
registration.waiting | A new version that finished installing and is waiting | No update is pending |
registration.installing | A version that is currently running its install event | Nothing is installing |
The controller is the obvious choice, and it is wrong surprisingly often. On the very first visit the page loads before the worker exists, so it stays uncontrolled until it navigates or the worker calls clients.claim(). A hard reload (Shift plus the reload button, or Ctrl+Shift+R / Cmd+Shift+R) bypasses the service worker by design: the spec's Handle Fetch algorithm returns early for a navigation "initiated with a shift+reload or equivalent", so the resulting document is uncontrolled even though a perfectly good active worker exists.
navigator.serviceWorker.ready covers both cases. It resolves with the registration whose scope matches the page URL as soon as that registration has an active worker, whether or not the worker controls the page. It never settles when the page is outside every registration's scope, so always race it against a timeout.
/**
* Resolve the ServiceWorker object that should receive page -> worker messages.
* `controller` is null on first visit and after a hard reload, but the
* registration's active worker can still receive messages in both cases.
*/
export async function getServiceWorkerTarget({ timeout = 5000 } = {}) {
const container = navigator.serviceWorker;
if (!container) {
throw new DOMException("Service workers are unavailable", "NotSupportedError");
}
if (container.controller) return container.controller;
// `ready` never settles for pages outside every registration's scope.
let timer;
try {
const registration = await Promise.race([
container.ready,
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new DOMException("No active service worker", "TimeoutError")),
timeout,
);
}),
]);
return registration.active;
} finally {
clearTimeout(timer);
}
}
Message a waiting or installing worker when the message is about that specific version, most commonly the SKIP_WAITING command behind an "update available" prompt (see Updating Service Workers). Messaging a non-active version is fully supported: the browser starts that worker if needed and dispatches the event to it.
What happens when you call postMessage()¶
ServiceWorker.postMessage() has two overloads, and both return undefined:
worker.postMessage(message, transfer); // transfer: array of transferable objects
worker.postMessage(message, { transfer }); // StructuredSerializeOptions dictionary
The Service Workers specification (§3.1.5) runs these steps:
- Serialize synchronously.
StructuredSerializeWithTransfer(message, transfer)runs on the caller's thread. A non-cloneable value (a function, a DOM node, aResponse) throws aDataCloneErrorright there, at the call site. Transferred objects are detached immediately. - Maybe skip. If the worker registered no
messagelistener during its initial script evaluation, the Should Skip Event algorithm lets the browser drop the message without even starting the worker. - Run the worker. If the worker is stopped, the browser starts it and evaluates its top-level script. If startup fails, the message is silently dropped.
- Dispatch. A task on the worker's event loop builds the event's
source(aWindowClientfor a window, aClientfor a dedicated or shared worker, aServiceWorkerobject when another service worker sent it), deserializes the data in the worker's realm and dispatches anExtendableMessageEventnamedmessage. If deserialization fails, it dispatchesmessageerrorinstead.
There is no delivery receipt. If you need to know that the worker received and processed a message, ask for a reply (see Request and response with MessageChannel).
import { getServiceWorkerTarget } from "./sw-target.js";
const worker = await getServiceWorkerTarget();
// Fire-and-forget command.
worker.postMessage({ type: "CLEAR_RUNTIME_CACHES" });
// Transfer a 4 MiB buffer instead of copying it. After this call
// `buffer.byteLength === 0` in the page: ownership moved to the worker.
const buffer = await (await fetch("/big.bin")).arrayBuffer();
worker.postMessage({ type: "IMPORT_BYTES", buffer }, { transfer: [buffer] });
Register the message listener at the top level of the worker script
Browsers decide whether a worker handles an event type from the listeners that exist after the initial evaluation of the script. A listener added later (inside a promise callback, or after an await in a module worker) may never see messages that arrive when the worker is started for them, and Chromium logs "Event handler of 'message' event must be added on the initial evaluation of worker script."
Receiving messages in the worker: ExtendableMessageEvent¶
Inside the worker, messages arrive as ExtendableMessageEvent, which extends ExtendableEvent and therefore has waitUntil().
| Attribute | Type | Value for a message from a page |
|---|---|---|
data | any | The deserialized clone of the message |
origin | string | Serialized origin of the sender's environment, in practice always your own origin |
lastEventId | string | Always the empty string |
source | Client, ServiceWorker, MessagePort or null | A WindowClient for a window, a Client for a dedicated or shared worker, a ServiceWorker for another service worker |
ports | frozen array of MessagePort | The MessagePorts from the transfer list, in their original order |
Only same-origin contexts can obtain a ServiceWorker object for your registration, so event.origin checks are defense in depth rather than a security boundary. Checking the type of event.source is more useful: it tells you whether a command came from a page, a dedicated worker or another version of your service worker, which matters for commands such as SKIP_WAITING.
const VERSION = "2026.09.25";
/** Command handlers. Each receives the event and returns a value or a promise. */
const commands = {
async CLEAR_RUNTIME_CACHES() {
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k.startsWith("runtime-")).map((k) => caches.delete(k)));
},
SKIP_WAITING() {
// Only meaningful when this worker is the waiting one.
return self.skipWaiting();
},
GET_VERSION(event) {
// Replying via event.source works even if the sender is not controlled.
event.source.postMessage({ type: "VERSION", version: VERSION });
},
};
self.addEventListener("message", (event) => {
const { data, source } = event;
if (!data || typeof data.type !== "string") return;
// Accept commands only from same-origin window/worker clients,
// not from other service workers.
if (!(source instanceof Client) || event.origin !== self.location.origin) return;
const command = Object.hasOwn(commands, data.type) ? commands[data.type] : null;
if (!command) return;
// waitUntil keeps the worker alive until the async work settles.
event.waitUntil(
Promise.resolve()
.then(() => command(event))
.catch((error) => console.error(`Command ${data.type} failed`, error)),
);
});
self.addEventListener("messageerror", (event) => {
// The payload could not be deserialized in this realm (for example a
// SharedArrayBuffer, which cannot cross into a service worker's agent cluster).
console.warn("Undeserializable message from", event.source?.id);
});
Keeping the worker alive: waitUntil() in message handlers¶
Because message is an extendable event, the worker is not considered idle while any promise passed to event.waitUntil() is pending. Without waitUntil(), the handler returns synchronously, the browser sees no pending work and can stop the worker in the middle of your await: an IndexedDB transaction, a fetch() or a cache write simply never completes. The worker is not "suspended and resumed later"; its whole global state is discarded.
waitUntil() is not a blank cheque. Each engine enforces its own ceiling:
| Engine | Idle termination | Ceiling for one event | Details |
|---|---|---|---|
| Chromium | 30 seconds after the last event finishes | 5 minutes per event in general (3 minutes for sync, about 90 seconds for push), then the worker is killed | Messages sent by another service worker get their timeout clamped to the sender's remaining time, so workers cannot keep each other alive indefinitely |
| Firefox | 30 seconds after each event (dom.serviceWorkers.idle_timeout) | A further 30 seconds for pending waitUntil() promises (dom.serviceWorkers.idle_extended_timeout) | Long work gets cut off much earlier than in Chromium |
| Safari | Not publicly documented | Not publicly documented | Treat the worker as short-lived; do not depend on minutes of runtime |
The Chromium values come from kServiceWorkerDefaultIdleDelayInSeconds (30, in third_party/blink/public/mojom/service_worker/service_worker.mojom) and ServiceWorkerVersion::kRequestTimeout (5 minutes, in service_worker_version.h); the Firefox values are the defaults in all.js. They are implementation details and can change, so design for "a few seconds" of work per message and move anything longer into Background Sync, Background Fetch or the page itself.
Sending messages from the service worker to pages¶
Reply to the sender with event.source¶
The cheapest reply needs no lookup at all: event.source is already a Client (or ServiceWorker) object with a postMessage() method. This works even for an uncontrolled sender, such as a page loaded with a hard reload, because the spec builds source from the sender's environment without looking at who controls it.
self.addEventListener("message", (event) => {
if (event.data?.type !== "PING") return;
event.source?.postMessage({ type: "PONG", receivedAt: Date.now() });
});
Enumerate clients with clients.matchAll()¶
| Option | Type | Default | Meaning |
|---|---|---|---|
includeUncontrolled | boolean | false | When false, return only clients whose active service worker is this worker. When true, return every client in the registration's storage key (the same origin, partitioned by top-level site where storage partitioning applies), controlled by any registration or none. |
type | "window", "worker", "sharedworker", "all" | "window" | Which kinds of clients to include. "worker" means dedicated workers. |
The algorithm (§4.3.2) contains several details that routinely surprise people:
- Clients that are not "execution ready" are skipped. A document becomes execution ready when it is created, not when it finishes loading, but a navigation that is still waiting for its response is not a client yet.
- Non-secure contexts are skipped, as are discarded browsing contexts and documents that are no longer the active document of their browsing context. Pages sitting in the back/forward cache are therefore invisible to
matchAll(). - The default excludes clients of other versions. An
installingorwaitingworker controls nothing, somatchAll()withoutincludeUncontrolled: truealways returns an empty array when called frominstall. Any progress reporting during installation needsincludeUncontrolled: trueor aBroadcastChannel. - The order is specified. Window clients that have been focused come first, most recently focused first; then windows that were never focused, in creation order; then worker clients in creation order.
list[0]is usually the tab the user last looked at. - Every call returns new objects. Do not compare
Clientobjects by identity; compareid.
/**
* Post a message to every window of this origin, optionally including
* pages that this worker does not control (e.g. during install).
*/
export async function postToWindows(message, { includeUncontrolled = false } = {}) {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled });
for (const client of windows) {
try {
client.postMessage(message);
} catch (error) {
// Only serialization errors throw here; a closed client is silently ignored.
console.error("postMessage to", client.id, "failed", error);
break; // the same payload will fail for every client
}
}
return windows.length;
}
Get one client with clients.get(id)¶
clients.get(id) resolves with the Client whose id matches, or undefined. Useful ids come from event.source.id in a message handler, FetchEvent.clientId (the document or worker that issued a subresource request) and FetchEvent.resultingClientId (the client a navigation will create). For a client that exists but is not execution ready yet, get() waits until it is ready or discarded, which makes resultingClientId usable for "tell the page that is about to load something". resultingClientId is supported in Chrome 72, Firefox 65 and Safari 16.
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(
(async () => {
const cached = await caches.match(event.request);
const response = cached ?? (await fetch(event.request));
if (cached) {
// Tell the new page it was served from cache; don't delay the response for it.
event.waitUntil(
self.clients.get(event.resultingClientId).then((client) =>
client?.postMessage({ type: "SERVED_FROM_CACHE", url: event.request.url }),
),
);
}
return response;
})(),
);
});
Because the page receives the message through the client message queue (next section), the message is not lost even though it may be sent before the page's scripts have run.
Receive messages in the page: navigator.serviceWorker and the client message queue¶
Client.postMessage() delivers to the page's ServiceWorkerContainer, that is navigator.serviceWorker, as a plain MessageEvent: data, origin (the worker's origin), source (the ServiceWorker object that sent it, identical to registration.active, registration.waiting or registration.installing in that realm) and ports. Listening on window catches nothing; that is the most common reason for "the message never arrives".
Messages are not dispatched immediately. Each ServiceWorkerContainer has a client message queue that starts disabled, and incoming messages wait in it until one of these happens:
- script assigns
navigator.serviceWorker.onmessagefor the first time; - script calls
navigator.serviceWorker.startMessages(); - the document finishes parsing; per MDN, queued messages are dispatched once the
DOMContentLoadedevent has fired.
addEventListener("message", …) does not enable the queue. That is deliberate: it lets a page that registers listeners from several modules or late-loading scripts attach them all before the first message is delivered, without losing messages the worker sent during load.
const container = navigator.serviceWorker;
container.addEventListener("message", (event) => {
// `event.source` is a ServiceWorker; it may be a waiting or installing version.
const { data } = event;
switch (data?.type) {
case "SERVED_FROM_CACHE":
showOfflineBadge(data.url);
break;
case "SYNC_COMPLETE":
markOutboxSent(data.ids);
break;
default:
break;
}
});
container.addEventListener("messageerror", (event) => {
console.warn("Undeserializable message from the service worker", event);
});
// addEventListener() leaves the queue disabled; start it once all listeners exist.
container.startMessages();
Messages to closed, hidden and back/forward-cached clients¶
Client.postMessage() never throws for a client that has gone away. The spec's algorithm looks for the target client and simply returns when it no longer exists. Hidden tabs still receive messages normally.
Back/forward cache interplay is more subtle. matchAll() excludes documents that are not the active document of their browsing context, so bfcached pages are not listed. If you post to a Client object you obtained earlier and that page is now in the back/forward cache, Chromium evicts the page from the cache (not-restored reason ServiceWorkerPostMessage) and drops the message. Holding Client objects for a long time and posting to them later therefore silently costs you instant back navigations. Query clients again when you need them.
Request and response with MessageChannel¶
How MessagePort pairs behave¶
new MessageChannel() returns two entangled ports, port1 and port2. You keep one and transfer the other inside a postMessage() transfer list; the receiver finds it in event.ports. From then on the two ports form a private, ordered, bidirectional pipe.
- A port's message queue starts disabled, exactly like the client message queue. Assigning
port.onmessageenables it; withaddEventListener("message", …)you must callport.start(). port.close()disentangles the pair. Messages posted afterwards are discarded without error. The HTML Standard defines acloseevent that fires on the remaining port when its partner is closed or its owner goes away, but support is not yet consistent across engines, so do not build liveness detection on it.- A port whose other end lives in a service worker dies silently with the worker. When the worker is stopped, the page's port stays open, and messages you post into it go nowhere. This is why long-lived "connections" to a service worker are an anti-pattern and every request needs a timeout.
- Ports are themselves transferable, so you can hand a port to a dedicated worker or an iframe and let it talk to the service worker directly.
Using a fresh channel per request is the idiomatic pattern. It gives you a natural correlation (the reply can only come back on that port), no global message listener that has to demultiplex responses, and a clean teardown: close the port and the request is over.
sequenceDiagram
participant Page
participant SW as Service worker
Page->>Page: new MessageChannel()
Page->>SW: postMessage({type: "GET_STATS"}, [port2])
Note over SW: browser starts the worker if needed
SW->>SW: event.waitUntil(work)
SW-->>Page: event.ports[0].postMessage(result)
Page->>Page: resolve promise, port1.close() A promise wrapper for page-to-worker requests¶
import { getServiceWorkerTarget } from "./sw-target.js";
/**
* Send `message` to the service worker and resolve with its reply.
* The worker must answer on event.ports[0] with { ok: true, value }
* or { ok: false, error: { name, message } }.
*/
export async function requestFromServiceWorker(message, { timeout = 10_000, transfer = [] } = {}) {
const target = await getServiceWorkerTarget({ timeout });
const { port1, port2 } = new MessageChannel();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
cleanup();
// The worker may have been terminated, or it has no handler for this message.
reject(new DOMException(`No reply to ${message?.type} within ${timeout} ms`, "TimeoutError"));
}, timeout);
function cleanup() {
clearTimeout(timer);
port1.onmessage = null;
port1.close();
}
port1.onmessage = ({ data }) => {
cleanup();
if (data?.ok) {
resolve(data.value);
} else {
const error = new Error(data?.error?.message ?? "Service worker request failed");
error.name = data?.error?.name ?? "Error";
reject(error);
}
};
try {
// port2 goes first so the worker can always find it at event.ports[0].
target.postMessage(message, [port2, ...transfer]);
} catch (error) {
cleanup();
reject(error); // DataCloneError: `message` contained something not cloneable
}
});
}
Answering on event.ports[0] in the worker¶
self.addEventListener("message", (event) => {
const [port] = event.ports;
if (!port || event.data?.type !== "GET_STORAGE_STATS") return;
event.waitUntil(
(async () => {
try {
const [estimate, cacheNames] = await Promise.all([
navigator.storage.estimate(),
caches.keys(),
]);
port.postMessage({
ok: true,
value: { usage: estimate.usage, quota: estimate.quota, caches: cacheNames },
});
} catch (error) {
port.postMessage({ ok: false, error: { name: error.name, message: error.message } });
} finally {
port.close();
}
})(),
);
});
Two details in that handler matter. The reply is posted from inside the promise passed to waitUntil(), so the worker cannot be stopped between computing the result and sending it. And errors are converted to a plain { name, message } object: Error objects are serializable in current engines, but custom subclasses lose their prototype and custom properties on the way, so an explicit shape is more predictable.
Asking a page from the service worker¶
The same pattern works in reverse. The worker creates the channel, transfers one port to a client, and awaits the reply with a timeout. The page answers on event.ports[0].
/**
* Ask a client (window or worker) a question and await its answer.
* Resolves with the reply payload; rejects on timeout.
*/
export function askClient(client, message, { timeout = 3000 } = {}) {
const { port1, port2 } = new MessageChannel();
return new Promise((resolve, reject) => {
const settle = (fn, value) => {
clearTimeout(timer);
port1.onmessage = null;
port1.close();
fn(value);
};
// A closed client never answers and postMessage() does not throw for it,
// so the timeout is the only way to find out.
const timer = setTimeout(
() => settle(reject, new DOMException("Client did not answer", "TimeoutError")),
timeout,
);
port1.onmessage = ({ data }) => settle(resolve, data);
try {
client.postMessage(message, [port2]);
} catch (error) {
settle(reject, error); // DataCloneError: `message` is not cloneable
}
});
}
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type !== "GET_UI_STATE") return;
event.ports[0]?.postMessage({ route: location.pathname, unsavedChanges: editor.isDirty() });
});
navigator.serviceWorker.startMessages();
Building a tiny RPC layer¶
Once an app has more than a handful of messages, ad hoc type switches and per-call channels turn into boilerplate. A small RPC layer standardizes the envelope, errors, timeouts, cancellation and progress reporting. The protocol below is intentionally tiny:
| Direction | Envelope | Meaning |
|---|---|---|
Page → worker (via postMessage) | { protocol, kind: "call", id, method, params } plus a port | Invoke method; answers arrive on the transferred port |
| Worker → page (port) | { protocol, kind: "progress", value } | Zero or more progress updates |
| Worker → page (port) | { protocol, kind: "result", value } | Success; the call is over |
| Worker → page (port) | { protocol, kind: "error", error: { name, message, data } } | Failure; the call is over |
| Page → worker (port) | { protocol, kind: "abort" } | The caller timed out or aborted |
const PROTOCOL = "sw-rpc/1";
let nextId = 0;
export class RpcError extends Error {
constructor(name, message, data) {
super(message);
this.name = name;
this.data = data;
}
}
async function resolveTarget(timeout) {
const container = navigator.serviceWorker;
if (!container) throw new RpcError("NotSupportedError", "Service workers are unavailable");
if (container.controller) return container.controller;
let timer;
try {
const registration = await Promise.race([
container.ready, // never settles outside every scope, hence the race
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new RpcError("TimeoutError", "No active service worker")),
timeout,
);
}),
]);
return registration.active;
} finally {
clearTimeout(timer);
}
}
/**
* Call `method` in the service worker.
* @param {string} method
* @param {*} params structured-cloneable arguments
* @param {{timeout?: number, signal?: AbortSignal, transfer?: Transferable[],
* onProgress?: (value: any) => void}} [options]
*/
export async function call(method, params, options = {}) {
const { timeout = 10_000, signal, transfer = [], onProgress } = options;
signal?.throwIfAborted();
const target = await resolveTarget(timeout);
// The signal may have fired while we waited for `ready`; its abort event
// will not fire again, so check before wiring up the listener.
signal?.throwIfAborted();
const id = ++nextId;
const { port1, port2 } = new MessageChannel();
return new Promise((resolve, reject) => {
let timer;
const finish = (settle, value) => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
port1.onmessage = null;
port1.close();
settle(value);
};
const abortRemote = () => port1.postMessage({ protocol: PROTOCOL, kind: "abort" });
function onAbort() {
abortRemote();
finish(reject, signal.reason);
}
port1.onmessage = ({ data }) => {
if (data?.protocol !== PROTOCOL) return;
if (data.kind === "progress") {
onProgress?.(data.value);
} else if (data.kind === "result") {
finish(resolve, data.value);
} else if (data.kind === "error") {
const { name, message, data: extra } = data.error ?? {};
finish(reject, new RpcError(name ?? "Error", message ?? "RPC failed", extra));
}
};
timer = setTimeout(() => {
abortRemote();
finish(reject, new RpcError("TimeoutError", `${method} timed out after ${timeout} ms`));
}, timeout);
signal?.addEventListener("abort", onAbort, { once: true });
try {
target.postMessage(
{ protocol: PROTOCOL, kind: "call", id, method, params },
[port2, ...transfer],
);
} catch (error) {
finish(reject, error); // DataCloneError from non-cloneable params
}
});
}
const PROTOCOL = "sw-rpc/1";
function serializeError(error) {
const out = {
name: typeof error?.name === "string" ? error.name : "Error",
message: typeof error?.message === "string" ? error.message : String(error),
data: null,
};
try {
out.data = structuredClone(error?.data ?? null); // drop non-cloneable extras
} catch {
out.data = null;
}
return out;
}
/**
* Expose `methods` to pages. Call this synchronously at the top level of
* the worker script so the message listener exists after initial evaluation.
*/
export function exposeRpc(methods, { allowSource = (source) => source instanceof Client } = {}) {
self.addEventListener("message", (event) => {
const msg = event.data;
if (msg?.protocol !== PROTOCOL || msg.kind !== "call") return;
const [port] = event.ports;
if (!port) return;
const reply = (payload) => {
try {
port.postMessage({ protocol: PROTOCOL, ...payload });
} catch (error) {
// The handler returned something that cannot be cloned.
port.postMessage({ protocol: PROTOCOL, kind: "error", error: serializeError(error) });
}
};
if (event.origin !== self.location.origin || !allowSource(event.source)) {
reply({ kind: "error", error: { name: "SecurityError", message: "Caller not allowed" } });
port.close();
return;
}
const method = Object.hasOwn(methods, msg.method) ? methods[msg.method] : null;
if (typeof method !== "function") {
reply({ kind: "error", error: { name: "NotFoundError", message: `No method ${msg.method}` } });
port.close();
return;
}
const controller = new AbortController();
port.onmessage = ({ data }) => {
if (data?.protocol === PROTOCOL && data.kind === "abort") {
controller.abort(new DOMException("Caller aborted", "AbortError"));
}
};
const context = {
id: msg.id,
source: event.source,
signal: controller.signal,
progress: (value) => {
if (!controller.signal.aborted) reply({ kind: "progress", value });
},
};
event.waitUntil(
(async () => {
try {
const value = await method(msg.params, context);
if (!controller.signal.aborted) reply({ kind: "result", value });
} catch (error) {
if (!controller.signal.aborted) reply({ kind: "error", error: serializeError(error) });
} finally {
port.onmessage = null;
port.close();
}
})(),
);
});
}
With both halves in place, the worker declares its API in one object and pages call it like a local async function:
import { exposeRpc } from "./sw-rpc-server.js";
const VERSION = "2026.09.25";
exposeRpc({
getVersion: () => VERSION,
async cacheArticles({ urls, cacheName }, { progress, signal }) {
const cache = await caches.open(cacheName);
let done = 0;
for (const url of urls) {
signal.throwIfAborted(); // the page timed out or navigated away
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`${url} responded ${response.status}`);
await cache.put(url, response);
progress({ done: ++done, total: urls.length });
}
return { cached: done };
},
async storageEstimate() {
const { usage, quota } = await navigator.storage.estimate();
return { usage, quota };
},
});
import { call, RpcError } from "./sw-rpc-client.js";
const bar = document.querySelector("progress");
try {
const { cached } = await call(
"cacheArticles",
{ urls: savedArticleUrls, cacheName: "articles-v1" },
{
timeout: 60_000,
onProgress: ({ done, total }) => {
bar.max = total;
bar.value = done;
},
},
);
announce(`${cached} articles available offline`);
} catch (error) {
if (error instanceof RpcError && error.name === "TimeoutError") {
announce("Saving for offline took too long; try again on a better connection.");
} else {
throw error;
}
}
The design choices are all consequences of the platform rules above. The client resolves its target through ready so calls work from uncontrolled pages. Each call owns a port, so replies cannot cross. The worker wraps the whole call in waitUntil(), and the caller's timeout covers the case where the worker is killed anyway (in Firefox, the 60-second call above can exceed the idle-plus-grace budget on a slow connection, which is exactly when the timeout path runs). Module service workers, which this example uses for import, are supported in Chrome 91, Safari 15 and Firefox 147; with a classic worker, inline the server code or load it with importScripts() after removing the export keyword.
BroadcastChannel for one-to-many messaging¶
BroadcastChannel is a named bus shared by every same-origin context that opens a channel with the same name: windows, iframes, dedicated and shared workers and service workers. It has been available in Chrome 54, Firefox 38 and Safari 15.4.
const channel = new BroadcastChannel("auth");
channel.postMessage({ type: "LOGGED_OUT" }); // to every *other* "auth" channel object
channel.onmessage = (event) => { /* event.data */ };
channel.close();
Its semantics, from the HTML Standard, differ from postMessage() on workers and clients in ways that decide when you can use it:
- Scope is the storage key, not the origin alone. Delivery targets channel objects whose environment has the same storage key, so in browsers that partition storage a same-origin iframe embedded on another site is on a different bus. See Privacy & Storage Partitioning.
- The sender's own object never receives its message, but other channel objects with the same name in the same context do.
- No transfer list.
BroadcastChannel.postMessage(message)takes only the message; ports, streams andArrayBufferownership cannot be handed over. Everything is copied. event.sourceisnullandevent.portsis empty. There is no way to reply to "the sender" except by broadcasting again, so include your own correlation ids when needed.- It cannot wake a service worker. A stopped worker has no channel objects, so it receives nothing. Broadcasting from the worker is reliable (the worker is running when it sends), but broadcasting to it is best effort.
- Delivery order follows channel creation order within an agent, and messages from one sender arrive in the order sent.
| Need | clients.matchAll() + postMessage() | BroadcastChannel |
|---|---|---|
| Reach uncontrolled pages | Only with includeUncontrolled: true | Always |
| Reach other workers and iframes | With type: "all" | Always, if they opened the channel |
| Transfer objects | Yes | No |
| Target one specific client | Yes | No (filter on receipt) |
| Wake the service worker | Not applicable | No |
| Works identically from pages and workers | No (clients exists only in service workers) | Yes |
A robust cross-tab design usually combines both: pages broadcast state changes to each other over BroadcastChannel, and anything that needs the service worker to act goes through postMessage() on the worker, which guarantees the worker runs.
Structured clone and transferables¶
Every channel on this page serializes messages with the structured clone algorithm from the HTML Standard. Knowing its rules avoids both DataCloneErrors and subtle data loss.
| Value | Clone | Transfer | Notes |
|---|---|---|---|
Primitives (except Symbol), BigInt | Yes | No | Symbol values throw DataCloneError |
| Plain objects, arrays | Yes | No | Only own enumerable string-keyed properties; getters are invoked and replaced by values; prototypes are lost, so class instances arrive as plain objects |
Date, RegExp, Map, Set | Yes | No | RegExp.lastIndex is not preserved |
ArrayBuffer, typed arrays, DataView | Yes | ArrayBuffer yes | Transfer detaches the sender's buffer (byteLength becomes 0) |
Blob, File, FileList | Yes | No | Cheap: the underlying bytes are shared, not copied |
ImageData, ImageBitmap | Yes | ImageBitmap yes | |
MessagePort | No | Yes | Must be in the transfer list |
ReadableStream, WritableStream, TransformStream | No | Yes | Transferable in Chrome 87 and Firefox 103. Safari 27 transfers ReadableStream only; WritableStream and TransformStream are not transferable in Safari |
Error and its standard subclasses, DOMException | Yes | No | name and message survive; custom subclasses and extra properties do not reliably |
CryptoKey, FileSystemHandle | Yes | No | Useful for handing keys or file handles to a worker |
SharedArrayBuffer | Only within one agent cluster | No | The sender throws unless it is cross-origin isolated; a service worker is always its own agent cluster, so the worker receives messageerror |
Functions, DOM nodes, Request, Response, Headers, Cache | No | No | Throws DataCloneError at the sender |
Practical consequences:
- Serialize
Request/Responseyourself. Send{ url, method, headers: [...headers] }and a bodyArrayBufferorBlob, or better, store the response in Cache Storage and send the URL. - Large payloads cost main-thread time. Serialization runs synchronously on the sender. Transfer
ArrayBuffers, sendBlobs (which share bytes), or write data to IndexedDB or the Cache API and pass only a key. - Streams are the zero-copy way to move large or progressive data between a page and a worker, now that all three engines can transfer a
ReadableStream(Safari 27 transfers onlyReadableStream, notWritableStreamorTransformStream). Transfer the stream in thetransferlist; the sender can no longer read it. - Validate on receipt. Structured clone preserves shape, not type: a message claiming
{ type: "SET_TOKEN", token: 42 }deserializes fine. Check types in the handler.
The Clients API in depth¶
The worker's self.clients object is the only way a service worker can see or touch its pages.
[Exposed=ServiceWorker]
interface Clients {
Promise<(Client or undefined)> get(DOMString id);
Promise<FrozenArray<Client>> matchAll(optional ClientQueryOptions options = {});
Promise<WindowClient?> openWindow(USVString url);
Promise<undefined> claim();
};
Client and WindowClient properties¶
| Property | Interface | Value |
|---|---|---|
id | Client | Opaque unique string for the environment; stable for the client's lifetime, not across reloads |
url | Client | The client's creation URL per the spec. As MDN notes, it is not updated by same-document navigations (fragment changes, history.pushState(), Navigation API interceptions), so in a SPA it is the URL the document was loaded with |
type | Client | "window", "worker" or "sharedworker" |
frameType | Client | "top-level", "nested" (iframe), "auxiliary" (opened with an opener, e.g. window.open()), or "none" (workers) |
visibilityState | WindowClient | "visible" or "hidden", snapshotted when the object was created |
focused | WindowClient | Whether the document had focus when the object was created |
ancestorOrigins | WindowClient | Origins of ancestor frames; implemented only in Safari (16 and later) |
visibilityState and focused are snapshots. The spec gathers them by queueing a task on the client's event loop while building the object, so they are accurate at that instant and stale a moment later. Call matchAll() again rather than caching the result. Because url may not reflect client-side routing, single-page apps that need the current route should ask the page (see Asking a page from the service worker).
clients.openWindow(): open a window from the worker¶
openWindow(url) creates a new top-level browsing context and navigates it. The spec algorithm:
- Parse
urlagainst the worker's base URL; failure rejects withTypeError. about:blankrejects withTypeError.- If no window of this origin has transient activation, reject with
InvalidAccessError. - Create the browsing context and navigate it. If the resulting document's storage key differs from the registration's (a cross-origin URL, or a URL that redirects to another origin), the promise resolves with
null: you can open cross-origin windows but you cannot get a handle to them. - Otherwise resolve with a
WindowClientfor the new window.
Step 3 is where real browsers diverge from the prose of the spec and from each other. The user's click on a notification happens outside any page, so engines grant the worker a temporary permission instead:
- Chromium grants one window interaction token when it dispatches
notificationclick,paymentrequestorbackgroundfetchclick.openWindow()andfocus()each consume a token and otherwise reject withInvalidAccessError("Not allowed to open a window." / "Not allowed to focus a window."). The token is revoked when the event finishes and, fornotificationclick, no later than 10 seconds after the most recentwaitUntil()call (wait_until_observer.cc). Amessageevent does not grant a token, even if the sending page just received a click. - Firefox allows popups from a worker only as the result of a notification click, within
dom.webnotifications.disable_open_click_delay: 1000 ms on desktop and 5000 ms on Android. - Chrome on Android, and according to MDN recent Chrome on Windows, may open the URL inside the window of an installed web app whose scope contains it, instead of a browser tab.
Since only one token is available in Chromium, "open a window, then focus it" wastes it: the new window is already focused, and the second call rejects. Decide first, then make exactly one call.
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const target = new URL(event.notification.data?.url ?? "/", self.location.origin);
event.waitUntil(
(async () => {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// Most recently focused first, so the first match is the best candidate.
const existing = windows.find((c) => new URL(c.url).pathname === target.pathname);
if (existing) {
await existing.focus(); // consumes the single Chromium token
return;
}
const opened = await self.clients.openWindow(target.href);
// `opened` is null for cross-origin targets; nothing else to do then.
opened?.postMessage({ type: "OPENED_FROM_NOTIFICATION", tag: event.notification.tag });
})(),
);
});
More on notification handling lives in Notifications API and Push Notifications.
WindowClient.focus() and navigate()¶
focus() runs the HTML focusing steps on the client's browsing context and resolves with a fresh WindowClient if the document ended up focused; otherwise it rejects with TypeError. It is subject to the same activation check as openWindow().
navigate(url) navigates an existing window, and has its own constraints:
urlmust parse and must not beabout:blank(TypeErrorotherwise).- The client must be controlled by the calling worker. Navigating a client of another registration, or an uncontrolled client from
matchAll({ includeUncontrolled: true }), rejects withTypeError. - A client whose document is not fully active (for example in the back/forward cache) rejects.
- If the navigation ends on a different origin, the promise resolves with
null. - No user activation is required: a worker can navigate its own pages at any time. Use that power sparingly; navigating away from unsaved work is hostile.
WindowClient.navigate() is supported in Chrome 49, Firefox 50 and Safari 16.
clients.claim(): take control without a reload¶
claim() makes the active worker the controller of every execution-ready, secure client for which its registration is the best scope match (the longest matching scope) and that is not already controlled by it. For each such client the browser sets the active worker and fires controllerchange on that page's navigator.serviceWorker. It rejects with InvalidStateError unless called by the active worker, so the usual place is the activate event:
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
await cleanUpOldCaches();
await self.clients.claim(); // pages loaded before the worker existed are now controlled
})(),
);
});
After claim(), navigator.serviceWorker.controller in those pages becomes non-null and their subsequent requests go through the worker. Their existing resources were not served by it, which is why claiming is a trade-off discussed in Lifecycle.
Production patterns¶
Update available prompt¶
The page watches for a new version reaching installed, shows a prompt, and messages the waiting worker when the user accepts. The reload happens on controllerchange, guarded so a first-install clients.claim() does not reload the page.
const hadController = Boolean(navigator.serviceWorker.controller);
const registration = await navigator.serviceWorker.register("/sw.js");
function offerUpdate(worker) {
const banner = document.querySelector("#update-banner");
banner.hidden = false;
banner.querySelector("button").onclick = () => worker.postMessage({ type: "SKIP_WAITING" });
}
// An update may already be waiting from a previous visit.
if (registration.waiting && hadController) offerUpdate(registration.waiting);
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
installing?.addEventListener("statechange", () => {
// "installed" with an existing controller means "update waiting".
if (installing.state === "installed" && navigator.serviceWorker.controller) {
offerUpdate(installing);
}
});
});
let reloading = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!hadController || reloading) return; // first install via claim(): no reload
reloading = true;
location.reload();
});
The worker side is the SKIP_WAITING command shown earlier. Other tabs of the app also receive controllerchange and reload with the same code; if that is unacceptable, broadcast the decision and let each tab decide. The trade-offs between prompting, reloading on the next navigation and activating immediately are compared in Updating Service Workers.
Precache progress during install¶
During install the new worker controls nothing, so it must either include uncontrolled clients or broadcast. BroadcastChannel is simplest because it also reaches pages that open while the install is running.
const PRECACHE = "precache-v42";
const PRECACHE_URLS = ["/", "/app.js", "/app.css", "/offline.html" /* ... */];
const installChannel = new BroadcastChannel("sw-install");
self.addEventListener("install", (event) => {
event.waitUntil(precacheWithProgress());
});
async function precacheWithProgress() {
const cache = await caches.open(PRECACHE);
const queue = [...PRECACHE_URLS];
const total = queue.length;
let done = 0;
// Four parallel fetchers: fast, without flooding the origin.
async function fetcher() {
while (queue.length > 0) {
const url = queue.shift();
const response = await fetch(url, { cache: "no-cache" });
if (!response.ok) throw new Error(`Precache failed for ${url}: ${response.status}`);
await cache.put(url, response);
done += 1;
installChannel.postMessage({ type: "PRECACHE_PROGRESS", version: PRECACHE, done, total });
}
}
try {
await Promise.all(Array.from({ length: 4 }, fetcher));
installChannel.postMessage({ type: "PRECACHE_DONE", version: PRECACHE, total });
} catch (error) {
installChannel.postMessage({ type: "PRECACHE_FAILED", version: PRECACHE, message: error.message });
throw error; // reject waitUntil so the install fails and is retried later
}
}
const channel = new BroadcastChannel("sw-install");
const meter = document.querySelector("#offline-meter");
channel.onmessage = ({ data }) => {
if (data?.type === "PRECACHE_PROGRESS") {
meter.max = data.total;
meter.value = data.done;
} else if (data?.type === "PRECACHE_DONE") {
meter.hidden = true;
announce("This app now works offline.");
}
};
See Precaching & Runtime Caching for what belongs in a precache.
Background sync status¶
A sync event runs with no page involved, but pages that are open want to know the outcome. Use includeUncontrolled: true so pages opened during a worker update are not missed.
self.addEventListener("sync", (event) => {
if (event.tag !== "outbox") return;
event.waitUntil(
(async () => {
const sentIds = await flushOutbox(); // throws to let the browser retry later
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
client.postMessage({ type: "SYNC_COMPLETE", ids: sentIds, lastChance: event.lastChance });
}
})(),
);
});
Background Sync itself is Chromium-only; the details are in Background Sync.
Auth token relay¶
Some apps keep a short-lived access token in page memory and want the service worker to attach it to API requests (for example, requests the worker replays offline). The worker can hold the token in memory, but memory disappears with the worker, so the relay must be able to ask a page again.
import { askClient } from "./sw-ask-client.js";
let accessToken = null; // in-memory only: lost whenever the worker stops
self.addEventListener("message", (event) => {
if (event.data?.type === "SET_ACCESS_TOKEN" && event.source instanceof WindowClient) {
accessToken = typeof event.data.token === "string" ? event.data.token : null;
}
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
// Never attach credentials to cross-origin or non-API requests.
if (url.origin !== self.location.origin || !url.pathname.startsWith("/api/")) return;
event.respondWith(fetchWithToken(event));
});
async function fetchWithToken(event) {
if (!accessToken && event.clientId) {
const client = await self.clients.get(event.clientId);
if (client) {
try {
const reply = await askClient(client, { type: "GET_ACCESS_TOKEN" }, { timeout: 2000 });
accessToken = typeof reply?.token === "string" ? reply.token : null;
} catch {
// No answer: send the request without a token and let the API return 401.
}
}
}
const headers = new Headers(event.request.headers);
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
return fetch(new Request(event.request, { headers }));
}
Token relays widen the blast radius
Any same-origin script can message your worker, so the relay adds no protection against XSS; it only moves where the token lives. Attach tokens only to your own API paths, never cache responses fetched with an Authorization header in shared caches, and clear the token on logout. Read Service Worker Security and Authentication & Passkeys before choosing this design.
Cross-tab logout¶
Logging out must clear state in every tab and in the worker. The page that initiates logout tells the worker (which guarantees the worker runs), the worker wipes its caches and in-memory secrets, broadcasts the event and navigates the windows it controls.
const authChannel = new BroadcastChannel("auth");
async function logout(reason) {
accessToken = null; // the in-memory token from the relay above
const keys = await caches.keys();
await Promise.all(keys.filter((k) => k.startsWith("user-")).map((k) => caches.delete(k)));
authChannel.postMessage({ type: "LOGGED_OUT", reason });
// navigate() only works on clients controlled by this worker.
const windows = await self.clients.matchAll({ type: "window" });
await Promise.allSettled(windows.map((c) => c.navigate(`/login?reason=${encodeURIComponent(reason)}`)));
}
self.addEventListener("message", (event) => {
if (event.data?.type === "LOGOUT" && event.source instanceof WindowClient) {
event.waitUntil(logout("user"));
}
});
const authChannel = new BroadcastChannel("auth");
// Uncontrolled tabs (hard reload, first visit) are not navigated by the worker,
// so every tab also listens on the channel.
authChannel.onmessage = ({ data }) => {
if (data?.type === "LOGGED_OUT" && !location.pathname.startsWith("/login")) {
location.assign(`/login?reason=${encodeURIComponent(data.reason)}`);
}
};
export async function logoutEverywhere() {
await fetch("/api/session", { method: "DELETE", credentials: "same-origin" });
const worker =
navigator.serviceWorker.controller ?? (await navigator.serviceWorker.ready).active;
worker.postMessage({ type: "LOGOUT" });
}
Common pitfalls¶
- Assuming
controllerexists. It isnullon first load, after a hard reload and outside the scope. Usereadywith a timeout, as ingetServiceWorkerTarget(). - Listening on
window. Worker-to-page messages fire onnavigator.serviceWorker. - Using
addEventListenerwithoutstartMessages(). Messages stay queued until the document finishes parsing, which looks like random latency during load. - Keeping state in worker globals. Tokens, ports, counters and "connected client" maps vanish when the worker stops. Persist what matters in IndexedDB and re-derive the rest.
- Long work without
waitUntil(). The worker can be stopped mid-await. WithwaitUntil(), work is still capped (5 minutes in Chromium, far less in Firefox). - No timeout on requests. A terminated worker never answers and never closes your port.
matchAll()from an installing worker withoutincludeUncontrolled. It returns an empty array because the installing worker controls nothing.- Calling
openWindow()orfocus()outsidenotificationclick. Rejects withInvalidAccessError; in Chromium a second call in the same event rejects too. - Posting non-cloneable objects.
Response,Request, functions and class instances either throw or lose their behavior. Send plain data. - Assuming ordering across channels. Messages on one channel arrive in order, but
messageandfetchevents use different task sources, andBroadcastChannelmessages are independent of worker messages. Do not rely on a message "arriving before" a request the page makes next. - Messaging the wrong version after an update. Until
controllerchangefires,controlleris the old worker; a command meant for the new code must go toregistration.waiting. - Holding
Clientobjects for later. Posting to a client that entered the back/forward cache evicts it in Chromium. Re-query withmatchAll()orget().
More general mistakes are collected in Pitfalls & Anti-Patterns.
Debugging messaging¶
- Chrome and Edge: DevTools → Application → Service workers shows each version's status and lets you stop, start and inspect the worker. Select the worker's context in the Console's context menu and run
await clients.matchAll({ includeUncontrolled: true, type: "all" })to see exactly what the worker sees.chrome://serviceworker-internalslists every registration with its console output. - Firefox:
about:debugging#/runtime/this-firefoxlists service workers with an Inspect button that opens a dedicated console and debugger. - Safari: Develop → Service Workers opens Web Inspector for a running worker.
- Test the hard-reload path. Reload with Ctrl+Shift+R / Cmd+Shift+R and confirm your messaging still works with
controller === null. - Test termination. Stop the worker in DevTools while a request is in flight and confirm the page's timeout path shows a sensible error.
- Log both ends of every channel, including
messageerrorlisteners, which are the only signal that a payload failed to deserialize.
The full tool tour is in Browser DevTools.
Browser support¶
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
ServiceWorker.postMessage(), Client.postMessage() | ✅ 40 | ✅ 44 | ✅ 11.1 |
ExtendableMessageEvent | ✅ 51 | ✅ 44 | ✅ 11.1 |
clients.matchAll() | ✅ 42 | ✅ 54 | ✅ 11.1 |
clients.get() | ✅ 51 | ✅ 45 | ✅ 11.1 |
clients.openWindow() | ✅ 40 | ✅ 44 | ✅ 11.1 |
clients.claim() | ✅ 42 | ✅ 44 | ✅ 11.1 |
WindowClient.focus(), visibilityState, focused | ✅ 42 | ✅ 44 | ✅ 11.1 |
WindowClient.navigate() | ✅ 49 | ✅ 50 | ✅ 16 |
WindowClient.ancestorOrigins | ❌ | ❌ | ✅ 16 |
ServiceWorkerContainer.startMessages() | ✅ 74 | ✅ 64 | ✅ 11.1 |
messageerror on navigator.serviceWorker | ✅ 80 | ✅ 65 | ✅ 16.4 |
navigator.serviceWorker inside dedicated workers | ❌ | ✅ 133 | ✅ 11.1 |
BroadcastChannel | ✅ 54 | ✅ 38 | ✅ 15.4 |
Transferable ReadableStream | ✅ 87 | ✅ 103 | ✅ 27 |
FetchEvent.resultingClientId | ✅ 72 | ✅ 65 | ✅ 16 |
Support data as of September 2026. Edge, Opera and Samsung Internet follow the Chromium version for these features. Safari versions apply to both macOS and iOS/iPadOS. Check live data on MDN's Clients reference and caniuse.com.
Further reading¶
On this site
- Service Worker Lifecycle: when workers start, stop, claim and activate
- Registration & Scope: which pages a worker controls
- Updating Service Workers: the waiting phase and
skipWaiting() - Handling Fetch Events:
clientId,resultingClientIdandrespondWith() - Static Routing API: routing requests without waking the worker
- Background Sync: deferring work until connectivity returns
- Service Worker Security: trust boundaries for messages
External references
- Service Workers specification: Client, Clients and ExtendableMessageEvent
- HTML Standard: Cross-document messaging, channel messaging and BroadcastChannel
- HTML Standard: Safe passing of structured data
- MDN: Clients
- MDN: ServiceWorkerContainer.startMessages()
- MDN: The structured clone algorithm
- MDN: Transferable objects
- Chrome for Developers: workbox-window
messageSW()