DEV Community

Cover image for The Silent Killer in Your Chrome Extension's Background
turboline-ai
turboline-ai

Posted on

The Silent Killer in Your Chrome Extension's Background

You ship a Chrome extension that maintains a persistent WebSocket connection. Everything works in development. QA signs off. Users start reporting that the connection "just drops" after a while, with no error, no warning, no stack trace. The browser console is clean. Your server logs show a clean close. Nobody did anything wrong, and yet here you are.

Welcome to Manifest V3's most quietly destructive behavior.

What's Actually Happening

MV3 replaced persistent background pages with service workers. That tradeoff comes with a fundamental constraint: service workers are ephemeral. Chrome will terminate a service worker after roughly 30 seconds of inactivity to reclaim resources. When it does, any open WebSocket connection that service worker was holding gets torn down immediately, silently, and without triggering the kind of error events you'd normally catch.

This is the part that makes the bug so difficult to find. Your onerror and onclose handlers may fire, but they fire inside a context that is already dying. By the time you try to log anything meaningful or trigger reconnection logic, the worker is gone. If you're relying on the close event to detect and recover from drops, you may find that recovery never happens at all.

The 30-second window resets whenever the service worker processes an event Chrome considers "activity." A message from a content script counts. A browser API call counts. A WebSocket message, as of Chrome 116, also counts, which is the key insight that unlocks the fix.

The Keepalive Pattern That Works

Chrome 116 introduced improved WebSocket support for service workers. When a WebSocket message is received, Chrome now recognizes that as activity and resets the termination timer. This means you can keep the service worker alive as long as the WebSocket connection itself is alive, provided messages are flowing in at least every 30 seconds.

The practical implementation is straightforward: send a ping from your extension to your server on an interval shorter than the termination window. Twenty seconds is a comfortable margin.

// In your MV3 service worker
let ws;
let keepAliveInterval;

function connect() {
  ws = new WebSocket("wss://your-server.example.com");

  ws.addEventListener("open", () => {
    startKeepAlive();
  });

  ws.addEventListener("message", (event) => {
    if (event.data === "pong") return; // ignore keepalive responses
    // handle real messages
  });

  ws.addEventListener("close", () => {
    stopKeepAlive();
    // reconnect logic here
  });
}

function startKeepAlive() {
  keepAliveInterval = setInterval(() => {
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: "ping" }));
    }
  }, 20000);
}

function stopKeepAlive() {
  clearInterval(keepAliveInterval);
}
Enter fullscreen mode Exit fullscreen mode

Your server needs to respond to these pings. It doesn't matter what the response looks like, only that something comes back to reset the activity timer on the Chrome side. A simple pong message is enough.

The Part Most Guides Skip

The keepalive interval solves the steady-state problem, but there's a subtle edge case worth knowing about. When your service worker first starts up, it has about 30 seconds before Chrome considers it idle. If your WebSocket handshake takes longer than expected, or if there's a connection delay, you might not receive that first inbound message in time.

To handle this, make sure your connect() call happens as early as possible in the service worker lifecycle, ideally directly in response to the install or activate event, or triggered immediately when the extension starts. Do not wait for user interaction to establish the connection if you need it to be persistent from the start.

Also worth noting: if your service worker is terminated and the WebSocket closes, Chrome may or may not restart the worker depending on whether something else triggers it. You cannot rely on the close event alone to drive reconnection. A more robust approach uses the chrome.alarms API to periodically wake the service worker and check connection state, since alarms fire even after a worker has been terminated and will cause Chrome to restart it.

// Register an alarm to check connection health
chrome.alarms.create("connectionCheck", { periodInMinutes: 1 });

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "connectionCheck") {
    if (!ws || ws.readyState !== WebSocket.OPEN) {
      connect();
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

This gives you a second layer of defense. The keepalive ping prevents termination while the connection is healthy. The alarm resurrects the connection if the worker was terminated anyway.

What This Costs You

The tradeoff is server load and bandwidth. If you have thousands of connected extensions, each sending a ping every 20 seconds, that adds up. For most applications it's negligible, but it's worth making the ping payload small and the server handler lightweight.

It's also worth being intentional about when you start and stop the keepalive. If the user is not actively using a feature that needs the connection, there's no reason to keep the service worker alive and add load to your server. Tie the keepalive lifecycle to actual user intent where you can.

The fundamental takeaway: MV3 service workers are not a drop-in replacement for persistent background pages when it comes to long-lived connections. They require you to actively participate in keeping them alive, and the mechanism for doing that is making sure Chrome sees real activity within every 30-second window. Once you understand that, the fix is straightforward. The danger is in the time you spend not knowing that's the problem.

Top comments (0)