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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Idle timer with and without a heartbeatWith a 20-second ping, each message resets the 30-second idle timer and the worker stays alive; when pings stop, the timer runs out, the worker is evicted and the socket closes.socket openevictionpingtimer resetpingtimer resetserver pushtimer resetpings stopped30 s idleevictedsocket clos…last messageclose code 1006 at server
Traffic every 20 seconds keeps both alive; stop the traffic and both die together.

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.

Socket lifetime tied to a viewerThe side panel connects a port; the worker opens the socket and forwards server events; when the panel closes, the port disconnects and the worker closes the socket and may go idle.Side panelService workerServerconnect({name:'live-view'})new WebSocket + autheventport.postMessage(event)panel closed → onDisconnectclose(1000)
The worker is awake while someone is looking, and asleep otherwise.

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.
WebSocket viability by engineWhether a WebSocket in the extension background survives idle periods, survives with a heartbeat, and survives while an extension page holds a port, in Chrome, Firefox and Safari.ScenarioChrome 116+FirefoxSafariIdle socket, no UIClosed at evictionClosed at suspendClosed quicklyHeartbeat, no UISurvivesUnreliableUnreliableExtension page holds a portSurvivesSurvivesMostly survives
Only the viewer-tied pattern works the same everywhere.

Verification

  1. Open the side panel and watch chrome://serviceworker-internals: the worker should stay “RUNNING”.
  2. In the worker’s DevTools Network panel, select the WebSocket and confirm a ping frame every twenty seconds and server events arriving.
  3. Close the side panel. Within about thirty seconds the socket should close with code 1000 and the worker should stop.
  4. Disable Wi-Fi with the panel open, re-enable it, and confirm reconnection with a resume message 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.

Other Core APIs & Cross-Browser Data Management Resources