DEV Community

seller-mind
seller-mind

Posted on

Chrome Kills My Service Worker After 30 Seconds, and That Cost Me Two Weeks

I ship a Chrome extension called TaskBoost. It adds quick-create overlays to Linear: hit Ctrl+Shift+L on any page, get a small form, type a title, and the issue lands in your workspace without you opening Linear at all.

The core of it is one function. The extension injects that function into the current tab with chrome.scripting.executeScript. The function builds a Shadow-DOM overlay, collects the title, and posts it to Linear's GraphQL API.

It worked perfectly for me for weeks. Then it didn't, and the reason took two weeks to understand because the bug had a very specific hiding place.

What MV3 does to your background script

Manifest V3 has no persistent background page. There's a service worker, and Chrome terminates it when it decides you're idle. The documented idle window is 30 seconds, extended by activity. In practice: open the popup, keep a debugger attached, and the worker stays alive. Leave the tab alone for half a minute and it's gone.

This is fine if your worker is stateless. Mine wasn't.

The injected overlay needed three things from the extension: the user's API key, their default team, and their default priority. So I did what seemed obvious with a background script. I kept a config object in a module-level variable and had the overlay ask for it over chrome.runtime.sendMessage.

The failure, when it finally showed up in a console I was watching:

Uncaught (in promise) Error: Could not establish connection.
Receiving end does not exist.
Enter fullscreen mode Exit fullscreen mode

The overlay rendered. The user typed a title. The submit went nowhere. No visible error, because the error lived in a message channel that no longer had a listener on the other end.

And the pattern was exactly the one that makes a bug hard to catch: it always worked when the worker was freshly woken — right after you clicked the extension icon — and always failed when the worker had been asleep, which is precisely when you use a keyboard shortcut. A shortcut's whole purpose is that you didn't touch the extension first.

I found it by accident. I left a tab alone for a minute, came back, hit the shortcut, and watched the form accept text and then do nothing. Then I clicked the icon, hit the shortcut again, and it worked. That's the whole diagnosis, and it took four days of not seeing it to get there.

Fix one: cache harder, which fixed nothing

My first fix was to cache the config in the worker and rebuild it on wake. That fixed nothing, because the problem was never that the worker didn't know the config. The problem was that the overlay had nothing to talk to.

My second fix was retry logic: if sendMessage throws, wait 50ms, retry, up to three times. That made it worse in a subtle way. Three retries at 50ms is 150ms of the user staring at a form that looks frozen, and the failure rate dropped without reaching zero. I had converted a deterministic bug into a flaky one and told myself I was making progress.

Time spent on those two attempts: roughly a week, spread across version bumps from v1.2.1 to v1.2.8. Each release fixed one symptom. One of them also introduced a new bug — every time the overlay opened I added a keydown handler and never removed it, so after ten opens a single Escape keypress fired ten handlers. That one is pure carelessness, and it's the kind of thing you find by reading your own diff, not by testing.

The fix that worked: delete the conversation

The version that actually worked removed the message channel entirely.

The injected function now does everything itself. It reads chrome.storage.local directly — the storage API is available in the injected context, not only in the worker — pulls the key and defaults, and calls Linear's GraphQL endpoint with fetch. No sendMessage, no listener, no dependency on the worker being alive. The worker's only remaining job is to call executeScript and get out of the way.

// service worker, essentially the whole thing
chrome.commands.onCommand.addListener(async (command) => {
  if (command !== 'create-issue') return;
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  await chrome.scripting.executeScript({
    target: { tabId: tab.id },
    func: openOverlay, // self-contained, serialized, no closures
  });
});
Enter fullscreen mode Exit fullscreen mode

That's it. The worker can die the instant after the await and nothing breaks, because nothing downstream depends on it.

The cost is real and I underestimated it: executeScript serializes the function to text and re-evaluates it in the page. It does not carry your module scope. No imports, no helper functions from another file, no constants defined outside the function body. Everything the overlay needs has to live inside that one function, or be fetched at runtime inside it. My 90-line helper module became a 240-line function with a duplicated HTML escaper and a duplicated date formatter, and every shared utility I add from now on gets written twice or inlined.

I also deleted the content script. The old architecture injected a content script on <all_urls> and the popup talked to it with chrome.tabs.sendMessage. Same "talk to a thing that might not be there" pattern, plus <all_urls> is what got my store submission flagged. Removing content_scripts from the manifest and routing everything through executeScript fixed the reliability problem and the review problem with the same edit.

Testing a bug that only exists after you stop looking

The other half of those two weeks went into figuring out how to test this at all. There is no unit test for "the platform killed my process." My first attempt was to close DevTools and click around manually, which is unreliable because I kept accidentally reviving the worker.

What works, and what's now a manual step before every release:

  1. Load the unpacked extension fresh, and use the shortcut immediately — this is the easy case and it always passed, which is why it was useless as a test.
  2. Reload the tab, then wait a full 60 seconds without touching the extension. Don't open the popup, don't open DevTools, don't click the icon.
  3. Use the keyboard shortcut cold.
  4. Submit the form and confirm the issue appears in Linear.

Step 2 is the whole test. Every bug I shipped in those two weeks would have died at step 3. It takes ninety seconds and I skipped it for eight version bumps because step 1 kept passing and I kept trusting it.

chrome://serviceworker-internals will show you the worker being stopped if you want to watch it happen rather than wait for it. There's also a "stop" button in the DevTools Application panel under Service Workers, which is the fastest way to reproduce this class of bug deliberately: stop the worker, then trigger your feature. If it breaks, you have the same architecture problem I had.

The numbers

  • Rework: two weeks, on and off, from first reproducible failure to a build I trusted.
  • Version bumps between the first report and the fix: v1.2.1 through v1.2.8.
  • Message round-trips in the overlay path after the fix: 0.
  • Latency the retry approach added on every failure: up to 150ms, and it still failed.
  • Package after the rewrite: 18 files, 58.2 KB, no content scripts, host permissions narrowed to Linear's domains.
  • Severity in practice: every user whose first action after a pause was the keyboard shortcut.

The trade-off I'd make again

I gave up a warm, stateful background worker for a stateless one that may already be dead when my code runs. Concretely: every overlay open now does a storage read instead of hitting an in-memory object, and I maintain a self-contained 240-line injected function instead of clean modules.

In exchange, the extension makes no lifecycle assumptions at all. There's no "worker might be asleep" branch anywhere in my code, because there's no conversation left to lose. The bug class is gone rather than patched.

The uncomfortable part is the week I spent adding retries to a channel I should have deleted on day one. I was preserving an architecture because it existed, not because it was load-bearing.

Question

If you maintain an MV3 extension: where's your line between what lives in the service worker and what gets pushed into the injected context? I've converged on "the worker only injects and never answers questions," and I want to know whether that survives contact with the two features I keep postponing — OAuth token refresh and scheduled sync. Both seem to need a background process that owns state — so where would you put them, and what did it cost you the first time you got it wrong?

Top comments (0)