Using WebSockets from an MV3 Service Worker
Keep a WebSocket working in a Manifest V3 extension: Chrome 116 lifetime rules, a 20-second heartbeat, reconnecting after eviction, opening sockets only while UI is visible, and Firefox and Safari behaviour.
Table of Contents
Your extension opens a WebSocket for live updates. It works while you watch it, then the messages stop: the socket was closed when Chrome evicted the idle service worker, and nothing reopened it. Or the opposite problem — a heartbeat keeps the socket alive so well that the worker never sleeps, and a user reports the extension in the browser’s task manager using CPU around the clock. WebSockets in MV3 are workable, but only with an explicit decision about when the worker should be awake. This guide sits under network requests and backend sync.
How sockets and worker lifetime interact
A WebSocket lives in the process of the context that opened it. When the service worker is terminated, every socket it owns is closed without a close frame, and the server sees the TCP connection drop. Before Chrome 116, an open socket did nothing to prevent that termination, so sockets in workers were close to useless. From Chrome 116, sending or receiving a WebSocket message counts as extension activity and resets the thirty-second idle timer. An idle socket — open but silent — still does not keep the worker alive. The practical consequence is that a socket survives exactly as long as traffic flows at least every thirty seconds, which turns the heartbeat interval into a direct control over how long the worker stays up.
Step-by-step: a socket that behaves
1. Require Chrome 116 and declare the host
1{
2 "minimum_chrome_version": "116",
3 "host_permissions": ["wss://realtime.acme.example/*"]
4}
Execution context: the manifest. On older Chrome versions the socket would close on every eviction regardless of traffic. The host permission is not strictly required for WebSockets — they are not subject to CORS — but it documents the dependency and is needed if you set a connect-src CSP. See setting minimum browser versions.
2. Open the socket with a heartbeat and a single owner
1// sw.js
2let socket = null;
3let heartbeat = null;
4
5export function connect() {
6 if (socket && socket.readyState <= WebSocket.OPEN) return socket;
7 socket = new WebSocket("wss://realtime.acme.example/v1/stream");
8 socket.addEventListener("open", async () => {
9 socket.send(JSON.stringify({ type: "auth", token: await getAccessToken() }));
10 heartbeat = setInterval(() => {
11 if (socket?.readyState === WebSocket.OPEN) socket.send('{"type":"ping"}');
12 }, 20_000);
13 });
14 socket.addEventListener("message", (e) => onRealtime(JSON.parse(e.data)));
15 socket.addEventListener("close", (e) => {
16 clearInterval(heartbeat);
17 socket = null;
18 if (wantRealtime) scheduleReconnect(e.code);
19 });
20 return socket;
21}
Execution context: the service worker. Twenty seconds sits safely inside the thirty-second idle window even if one interval is delayed. The readyState <= OPEN guard prevents a second socket from being opened while the first is still connecting — duplicate sockets double your server load and deliver every event twice. Sending the token as the first message rather than in the URL keeps it out of server access logs.
3. Open it only while someone is watching
A permanent socket keeps the worker alive all day. Tie the socket’s life to the UI that needs real-time data: open it when the side panel connects a port, close it when the last port disconnects.
1const viewers = new Set();
2let wantRealtime = false;
3
4chrome.runtime.onConnect.addListener((port) => {
5 if (port.name !== "live-view") return;
6 viewers.add(port);
7 wantRealtime = true;
8 connect();
9 port.onDisconnect.addListener(() => {
10 viewers.delete(port);
11 if (viewers.size === 0) {
12 wantRealtime = false;
13 socket?.close(1000, "no viewers");
14 }
15 });
16});
17
18function onRealtime(event) {
19 for (const port of viewers) port.postMessage(event);
20}
Execution context: the service worker. Ports from extension pages also keep the worker alive while open, so the worker stays up for exactly as long as a viewer exists, and the socket’s heartbeat becomes redundant while the panel is open — harmless, and still needed if the panel closes mid-reconnect. When the last viewer leaves, closing with code 1000 tells the server the disconnect was intentional.
4. Reconnect with backoff, and catch up after gaps
Networks drop. When the socket closes unexpectedly while a viewer still wants it, reconnect with exponential backoff and jitter, and ask the server for anything missed since the last event.
1let attempt = 0;
2let lastEventId = null;
3
4function scheduleReconnect(code) {
5 if (code === 1008 || code === 4401) return refreshTokenThenConnect(); // policy / auth
6 const delay = Math.min(30_000, 1000 * 2 ** attempt++) * (0.5 + Math.random() / 2);
7 setTimeout(() => wantRealtime && connect(), delay);
8}
9
10function onOpenResume() {
11 attempt = 0;
12 if (lastEventId) socket.send(JSON.stringify({ type: "resume", after: lastEventId }));
13}
Execution context: the service worker. Using setTimeout for reconnects is acceptable because a viewer’s open port keeps the worker alive; if the worker is evicted anyway, the next port connection calls connect() afresh. Persist lastEventId to chrome.storage.session so a brand-new worker can still resume. Close codes in the 4000 range are application-defined; agree them with your server.
5. Fall back when no viewer is open
For updates that matter while the UI is closed — a new message the user should be notified about — a socket is the wrong tool. Use web push or an alarm-driven poll, and reserve the socket for the moments the user is actively watching.
1chrome.alarms.create("poll-unread", { periodInMinutes: 5 });
2chrome.alarms.onAlarm.addListener(async ({ name }) => {
3 if (name !== "poll-unread" || wantRealtime) return; // socket covers it while open
4 const { unread } = await api("/v1/unread");
5 chrome.action.setBadgeText({ text: unread ? String(unread) : "" });
6});
Execution context: the service worker, top-level listener. Skipping the poll while a socket is open avoids double-counting. The five-minute period is a trade-off between freshness and battery; packed Chrome builds cannot go below one minute.
Cross-browser variation
- Chrome / Edge: message traffic extends worker lifetime from Chrome 116. Idle sockets do not. Termination closes the socket abruptly (close code 1006 at the server).
- Firefox: MV3 background scripts run as an event page by default; an open WebSocket does not by itself prevent suspension, but active ports from extension pages do. The same viewer-tied pattern works without changes.
- Safari: background pages and workers are suspended aggressively, sometimes within seconds. Treat sockets as viable only while an extension page is open and holding a port; for anything else, poll on open.
Verification
- Open the side panel and watch
chrome://serviceworker-internals: the worker should stay “RUNNING”. - In the worker’s DevTools Network panel, select the WebSocket and confirm a ping frame every twenty seconds and server events arriving.
- Close the side panel. Within about thirty seconds the socket should close with code 1000 and the worker should stop.
- Disable Wi-Fi with the panel open, re-enable it, and confirm reconnection with a
resumemessage carrying the last event id.
1→ {"type":"auth","token":"…"}
2→ {"type":"ping"} +20s
3← {"type":"item","id":"e-1042"}
4→ close 1000 "no viewers"
Execution context: the service worker’s DevTools, Network → WS → Messages. A missing close frame after the panel closes means a viewer port is leaking.
FAQ
Can I keep a socket open forever with a heartbeat?
In Chrome 116+, technically yes. Doing so keeps the worker permanently resident, which defeats the resource model MV3 exists for and is visible to users in the task manager. Reserve it for features users explicitly turn on.
Should the socket live in an offscreen document instead?
Offscreen documents are for DOM APIs, and Chrome closes them when their stated reason no longer applies; using one to host a socket is fragile and against the documented reasons. Tie the socket to a viewer or use push.
What about Server-Sent Events?
EventSource is not available in service workers. Use a streaming fetch and parse the event-stream format yourself, as shown in streaming responses with fetch in the service worker.
Related
- Receiving server push in an extension — updates without a long-lived connection.
- Long-lived ports vs one-time messages — the viewer ports this pattern relies on.
- Keeping service workers alive during long tasks — the broader lifetime rules.
- Network requests and backend sync — the parent topic.