Skip to content

Keeping a WebSocket Alive in a Chrome Extension Service Worker

7 min readModerok team

Why a WebSocket in an MV3 service worker dies after 30 seconds, the 20-second keepalive fix, and the failures that remain after you add it.

A WebSocket in a Chrome extension service worker stays open only while traffic keeps crossing it. Chrome's idle timer does not care that a socket exists, it watches for activity, so the fix is a message every 20 seconds plus a minimum version gate:

{
  "minimum_chrome_version": "116"
}
let webSocket = null;

function keepAlive() {
  const keepAliveIntervalId = setInterval(
    () => {
      if (webSocket) {
        webSocket.send('keepalive');
      } else {
        clearInterval(keepAliveIntervalId);
      }
    },
    // Set the interval to 20 seconds to prevent the service worker from becoming inactive.
    20 * 1000
  );
}

That is Chrome's own sample from Use WebSockets in service workers, and it is where most people stop reading, which is why their socket still dies in the field. The rest of this post is what breaks after it.

Why the WebSocket dies with the service worker

Chrome's service worker lifecycle guide lists three termination conditions. The first is the one you are hitting: "After 30 seconds of inactivity. Receiving an event or calling an extension API resets this timer."

The socket lives in the worker's global scope, so when the worker is torn down the connection goes with it. Chrome's WebSocket tutorial says so in its Background section: "Previously, a service worker could become inactive despite a WebSocket connection being active if no other extension events occurred for 30 seconds. This would terminate the service worker and close the WebSocket connection."

In development this rarely bites: DevTools is open and you are generating events. Our write-up on why a Chrome extension service worker keeps stopping covers the lifecycle itself.

What Chrome 116 changed, and what it did not

Chrome 116 is what makes a long-lived socket possible. From the lifecycle guide: "Active WebSocket connections now extend extension service worker lifetimes. Sending or receiving messages across a WebSocket in an extension service worker resets the service worker's idle timer."

Read that second sentence closely: sending or receiving messages, not having a connection. An idle socket resets nothing. The tutorial puts it as a window you have to hit: "From Chrome 116 on, you can keep a service worker with a WebSocket connection active by exchanging messages within the 30s service worker activity window."

The keepalive is not belt-and-braces on top of Chrome 116, it is the mechanism: Chrome 116 only made your heartbeat count.

Why 20 seconds and not 29

The window is 30 seconds and the interval has to leave room for a slow tick, since setInterval makes no timing guarantee. Chrome's sample explains itself in a comment: "Set the interval to 20 seconds to prevent the service worker from becoming inactive." Receiving counts too, so a server that already pushes that often makes keepAlive() redundant.

What still breaks: nothing wakes you when the socket closes

The keepalive cannot fix this one. Your heartbeat lives in a setInterval inside the worker, so when the socket closes (server restart, network change, laptop sleep, a send that never lands), webSocket is null, the interval clears itself on its next tick, and nothing is generating activity. The worker goes idle, Chrome terminates it, and your reconnect logic goes with it.

Reconnection has to be event-driven, and the only event that arrives on a schedule after the worker dies is an alarm. chrome.alarms needs its own permission, and its floor only dropped to match the worker in Chrome 120: "Alarms can now be set to a minimum period of 30s to match the service worker lifecycle." Before that the minimum was a minute, so a 30-second period means raising that 116 gate to 120.

{
  "permissions": ["alarms"],
  "minimum_chrome_version": "120"
}
async function ensureAlarm() {
  const existing = await chrome.alarms.get('ws-reconnect');
  if (!existing) {
    await chrome.alarms.create('ws-reconnect', { periodInMinutes: 0.5 });
  }
}

// Top level: runs whenever the worker starts, cold starts included.
ensureAlarm();

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'ws-reconnect') connect();
});

Call it from the top level rather than from onInstalled or onStartup. Those cover install, update and profile start, but not an ordinary cold start, while the top-level script runs whenever the worker starts, so one call covers every path. The get-before-create follows Chrome's own sample, and here it is load-bearing: a bare create() cannot pile up duplicates, since "If there is another alarm with the same name ... it will be cancelled and replaced by this alarm", but with no when or delayInMinutes set, "periodInMinutes is used as the default for delayInMinutes", so every wake would push the next fire another 30 seconds out.

An onAlarm delivery is an event, so it does reset the idle timer, but it is no keepalive: the same reference warns Chrome "may delay them an arbitrary amount more", and that unpacked, "there's no limit to how often the alarm can fire", so an unpacked build flatters you. Treat it as a periodic chance to notice the socket is gone, and measure packed. For the full pattern see chrome.alarms in Manifest V3.

Globals are gone, so make connect() idempotent

let webSocket = null is a global, and the lifecycle guide is blunt: "Any global variables you set will be lost if the service worker shuts down." After a restart it is null again, and every wake path you wired up (the alarm, a popup message, a tab event) can call connect(). Two sockets and two intervals is a real outcome.

Guard on readyState, and give each socket its own heartbeat instead of sharing the global:

function keepAlive(ws) {
  const keepAliveIntervalId = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send('keepalive');
    } else {
      clearInterval(keepAliveIntervalId);
    }
  }, 20 * 1000);
}

function connect() {
  if (webSocket && webSocket.readyState <= WebSocket.OPEN) return;
  const ws = new WebSocket('wss://example.com/ws');
  webSocket = ws;
  ws.onopen = () => keepAlive(ws);
  ws.onclose = () => {
    if (webSocket === ws) webSocket = null;
  };
}

Both changes earn their keep. Chrome's keepAlive() closes over the module-level webSocket, which is fine with one socket and leaky with two: readyState <= WebSocket.OPEN lets a CLOSING socket fall through so you can dial a replacement, and the old interval then sees a truthy webSocket pointing at the new socket, so instead of clearing it pings a connection it does not own. Taking ws as a parameter retires each interval with its socket, and the onclose check stops a dying socket nulling the live one.

readyState matters when you send, too: per MDN, send() throws InvalidStateError "if WebSocket.readyState is CONNECTING", and worse, "If you call send() when the connection is in the CLOSING or CLOSED states, the browser will silently discard the data." A keepalive that silently discards its ping looks exactly like one that works.

Three things the keepalive does not fix

The five-minute rule still applies. The second condition is "When a single request, such as an event or API call, takes longer than 5 minutes to process." Your heartbeat buys no relief: WebSocket traffic resets the idle timer, not this one.

A slow fetch is the third. The list ends with "When a fetch() response takes more than 30 seconds to arrive." Nothing to do with your socket, but it applies to the fallback below.

Chrome does not want you resident forever. The guide also says: "To optimize the resource consumption of your extension, avoid keeping your service worker alive indefinitely if possible." A 20-second heartbeat for someone who has not opened your extension in two days is what that line is about. Open the socket when there is a reason to, close it when done.

Is an offscreen document a better home for the socket?

It comes up often: an offscreen document is a real page, not subject to the worker's idle timer, and only AUDIO_PLAYBACK expires on its own ("All other reasons don't set lifetime limits"). But the documented Reason values are specific, and none names a WebSocket. The closest is WEB_RTC, which "Specifies that the offscreen document needs to use WebRTC APIs". Declaring a reason you are not using puts that claim in a string a Web Store reviewer reads.

Firefox: there is no service worker to keep alive

None of this transfers. MDN's background manifest key page says "background.service_worker is not supported (see Firefox bug 1573659)", so a Firefox MV3 extension runs background.scripts as an event page.

An event page idles out too, and MDN's background scripts page puts the window shorter: "Background scripts unload after a few seconds of inactivity." MDN documents no equivalent of the Chrome 116 rule, so do not assume WebSocket traffic resets a Firefox idle timer. It points to the same fix: DOM timers "do not remain active after an event page has idled", so reach for alarms.

If you only need to send data, skip all of it

Plenty of extensions reach for a WebSocket when what they have is telemetry. fetch works in a service worker and needs no keepalive, as long as you respect the 30-second response limit above.

That is the assumption Moderok's SDK is built on. It never tries to hold the worker open: unsent events are capped and written to chrome.storage.local (the configuration reference lists 500 persisted pending events), recovered when the worker next starts, then retried with backoff. If your socket exists only to report what your extension does, a queue that survives termination beats a heartbeat that cannot.