A user starts a job in your extension’s popup, closes the popup, and reopens it thirty seconds later to find the job gone — no error, no progress bar, just a clean “Start” button as if nothing happened. This is not a bug you introduced. It’s the default outcome of Manifest V3 — the current Chrome extension platform — whenever a popup and its background Service Worker are written as if they share memory. They don’t. That mismatch is well documented across official Chrome docs, production issue trackers, and even Anthropic’s own Claude in Chrome extension. It deserves to be treated as a known architectural failure mode, not a one-off mistake.

This article covers exactly why that happens, the evidence that it’s structural rather than anecdotal, and the checkpointed chrome.storage.session architecture that makes it stop happening.

Key Takeaways

  • Chrome terminates an idle extension Service Worker after ~30 seconds and wipes every module-scope variable with it — this is documented, intended behavior, not a bug (Chrome for Developers).
  • The popup and the Service Worker are separate execution contexts connected only by async message-passing, never shared memory — and the popup’s own JS state dies the instant it closes.
  • This exact failure mode is independently documented in production code from MetaMask, Tampermonkey, DuckDuckGo, Microsoft Playwright, and Anthropic’s own Claude in Chrome extension — it is structural, not a one-off bug.
  • The fix is a Single Source of Truth: persist every state transition to chrome.storage.session, rehydrate the popup from storage on mount, and drive UI updates from chrome.storage.onChanged instead of live message round-trips.
  • The most common trigger of “Could not establish connection. Receiving end does not exist” is a mechanical one — an onMessage listener that doesn’t return true before its first await — and it’s fixable in one line.

The MV2 Habit That Breaks in MV3

Manifest V2’s background page was a hidden, persistent HTML document that ran for the life of the browser session. It had a DOM, a window, and module-scope variables that survived indefinitely — so let currentJob = {...} at the top of a background script was a perfectly reasonable place to keep state, and a popup could call chrome.extension.getBackgroundPage() to read it directly. That mental model — “the background is always there, my variable is still valid” — is what Manifest V3 quietly invalidates.

MV3 replaces the persistent background page with an event-driven Service Worker — a background script that Chrome starts on demand and kills once it goes idle, rather than a page that runs continuously. Google’s stated reason is resource usage: an always-on background page burns memory and CPU for extensions the user isn’t actively using, and a worker that spins down when idle doesn’t. The tradeoff is that any code still carrying the MV2 assumption pays for that resource saving with silently lost state.

The Real Lifecycle Rules

Chrome’s official Service Worker lifecycle documentation sets three hard limits, and they’re smaller than most developers expect the first time they see them side by side:

Time Budget Before Chrome Kills Your Service Worker Three documented Chrome extension service worker shutdown thresholds: 30 seconds of inactivity, 30 seconds for a fetch response to arrive, and a 300-second (5-minute) ceiling on any single event or API call handler. Time Budget Before Chrome Kills Your Service Worker Documented shutdown thresholds, in seconds Idle timeout (noevents) 30 fetch() responsewindow 30 Max singleevent/API call 300
Source: Chrome for Developers, “The extension service worker lifecycle”, retrieved 2026-09-16.

Any event or extension API call resets the 30-second idle clock. That’s why a chatty extension can feel like its worker “never sleeps” during active use — and why the same worker vanishes within moments of the user going quiet. A handful of specific triggers extend the worker’s life beyond that idle window, each gated to a particular Chrome version:

Keep-alive triggerChrome versionWhat it actually does
WebSocket activity116+Active traffic on an open WebSocket resets the idle timer
chrome.debugger session118+An attached debugger session prevents termination outright
chrome.runtime.connectNative()105+Keeps the worker alive until the native host disconnects
Offscreen document messages109+Messages received from an offscreen document reset the timer
Long-lived port messages114+Sending a message through an open port resets the timer — merely opening the port does not

That last row is worth pausing on, because it’s a specific point where casual write-ups tend to get the direction backwards. Before Chrome 114, simply having a `chrome.runtime.connect()` port open did **not** keep a worker alive by itself. That didn’t stop developers from opening a port and assuming the keep-alive problem was solved. Chrome 114 changed the mechanism, not the assumption: it’s traffic *through* the port that resets the timer now, same as everything else on this list. An idle port, open or not, buys you nothing.

None of these extensions add up to an indefinite keep-alive. A small set of user-facing-prompt APIs — desktopCapture.chooseDesktopMedia(), identity.launchWebAuthFlow(), management.uninstall(), permissions.request() — are explicitly allowed to exceed the 5-minute ceiling because they’re waiting on a human, not because Chrome relaxed the rule generally.

Anatomy of a Vanishing Task

Here’s the failure sequence these rules produce in a popup that assumes live shared memory with its worker:

Anatomy of a vanishing task, in six steps Step 1: popup opens and sends a start-job message. Step 2: the service worker allocates progress only in a module-scope variable. Step 3: the user closes the popup; the job is assumed to keep running in the background. Step 4: after about 30 seconds of inactivity, Chrome terminates the service worker and frees its memory, destroying the in-memory progress. Step 5: the user reopens the popup, which queries a freshly cold-started worker with no memory of the job. Step 6: the UI renders an idle state and the task appears lost, even though the underlying work may have completed unobserved. 1 Popup opens, sends “start job” chrome.runtime.sendMessage() wakes the service worker and asks it to begin an async task. 2 Progress lives in a module-scope variable let currentJob = {…} — nowhere else. This is the MV2 habit: assume the background context is durable. 3 User closes the popup The popup’s own JS context is destroyed instantly. The job is assumed to keep running unattended. 4 ~30s of inactivity → Chrome terminates the worker The V8 isolate is freed. Every module-scope variable, including the job progress, is gone. Not paused — gone. 5 User reopens the popup A message wakes a brand-new worker instance that executes top to bottom with a clean, empty heap. 6 UI renders “idle” — the task looks lost The underlying work may have already finished server-side. What was destroyed is observability, not necessarily the work. Synthesized from Chrome for Developers’ service worker lifecycle docs and the production issues cited below.
How a popup-initiated task appears to vanish under Manifest V3’s default (unpersisted) state model.

Step 6 is the detail worth sitting with: nothing here proves the work was lost. A fetch already in flight when the worker died may complete and write its result server-side with nobody home to record it locally. What Manifest V3 actually destroys in this sequence is the UI’s ability to observe that the work happened — a distinction that matters because it changes the fix. You don’t need to make the worker immortal; you need to make its progress observable from outside its own memory.

This Is Structural, Not a Bug: The Evidence

It would be easy to treat this as a one-off mistake in one codebase. It isn’t. The same failure — a popup or UI surface losing track of a job the Service Worker was handling — shows up independently across unrelated codebases, unrelated maintainers, and even unrelated companies:

The Evidence, By Confidence Tier Count of independently documented reports of the popup/service-worker state desync failure, grouped by confidence tier: 8 exact-match production bugs (including issues in BrowserMCP, Anthropic’s own Claude in Chrome extension, and Microsoft Playwright), 4 confirmed directly by a maintainer or official Chrome documentation, 4 with independent corroboration across 2 or more unrelated codebases, and 3 anecdotal or isolated reports not treated as core evidence. The Evidence, By Confidence Tier Independently documented reports of this exact failure mode Exact-matchproduction bugs 8 Confirmed bymaintainer/docs 4 Independent, 2+codebases 4 Anecdotal /isolated 3 Source: Compiled from Chrome for Developers docs, Chromium issue tracker, GitHub, and arXiv:2507.13926 (research review, Sept 2026)

A sample of the exact-match and maintainer-confirmed reports, in production code:

ProjectWhat happenedResolution
Anthropic’s own Claude in Chrome extension (anthropics/claude-code #15239)The worker idles out after ~30s, silently dropping an in-progress autonomous browser-automation session; the next tool call fails until a manual refreshFiled as breaking “fire and forget” multi-step workflows; keepalive strategies under triage at time of writing
MetaMask (metamask-extension #14987)“Could not establish connection. Receiving end does not exist” — a message sent to a worker that isn’t currently listeningRoot cause: worker evicted or torn down mid-transition; recurs independently across Tampermonkey, the official chrome-extensions-samples repo, and smaller projects
DuckDuckGo Privacy Extension (PR #1485)Popup-close detection broke because MV3 removed chrome.extension.getBackgroundPage(), which MV2 code relied onMerged fix rewriting popup-close detection for MV3 — a maintainer-acknowledged, shipped correction
webext-redux (tshaddix/webext-redux #244)A Redux-over-messaging library used by extensions reporting 200k+ combined users assumes the background script permanently holds live stateMultiple independent maintainers converge on the same diagnosis: the original architecture “will never” work under MV3 without redesign
Microsoft Playwright (microsoft/playwright #39475)Chrome reuses the same CDP target ID when a worker restarts after idle, so Playwright’s own serviceworker event never fires for the new instanceFixed in Playwright by treating the restarted worker as an update to the existing object — even vendor browser-automation tooling had to be re-architected around this

That last row matters beyond its own bug report. If a browser-tooling team with direct access to Chromium engineers still had to rework its assumptions around this lifecycle, that’s a reasonable signal the behavior is a durable platform characteristic — not a rough edge Chrome is about to smooth over. A 2025 academic survey of real-world MV3 migrations (arXiv:2507.13926) found the state-management gap for non-persistent background workers was maintainers’ single most commonly cited migration blocker — ahead of every other MV3 restriction.

Why It Works in DevTools and Breaks in Production

If this failure is so common, why does it so often survive local testing? Because the most common way developers inspect a Service Worker — opening chrome://extensions, clicking “service worker” to launch DevTools against it — attaches a chrome.debugger session to that worker. And per the keep-alive table above, an active debugger session is one of Chrome’s documented triggers that prevents termination outright (Chrome 118+). With DevTools open, the 30-second idle timer simply doesn’t fire. Module variables persist, setTimeout callbacks run on schedule, and the popup appears to stay perfectly synchronized with the background state.

Close DevTools — which is exactly what happens the moment a real user installs the extension — and the debugger session goes with it. The 30-second and 5-minute rules resume immediately, and state that looked rock-solid all through development starts disappearing in production. This is worth calling out explicitly during code review: “I tested it and the state persisted” is not evidence the architecture is sound if the test happened with the inspector open.

Two Misconceptions Worth Retiring

Two specific points of confusion recur often enough across forum threads and mailing lists that they’re worth naming directly.

“An open popup keeps the worker alive.” It doesn’t, at least not by itself. Chrome’s update-lifecycle documentation states that an open popup, side panel, or options page prevents the extension from being considered idle for update-eligibility purposes. That’s a separate mechanism from the Service Worker’s own 30-second termination timer. Messaging activity between the popup and the worker resets that timer; the popup’s mere presence, with no traffic, does not. Conflating “not idle for updates” with “worker won’t be killed” is a documented, repeated misreading of the docs — not a genuine vendor/community disagreement.

“It always revives on the next event.” Official docs describe a dormant worker as reviving when a new event arrives. Independent reports on the Chromium extensions mailing list tell a different story. Multiple developers, on different operating systems, describe the identical symptom: a worker that becomes inactive and stays inactive indefinitely, surviving even toggling the extension off and on. Only a full reinstall or browser restart recovers it in these reports. This is a genuine open discrepancy between documented and reported behavior. It doesn’t change the architecture recommendation below — you should persist state regardless — but it’s a reason to build a manual “resync” affordance for users, rather than assuming a stuck extension will always fix itself.

The Fix: A Single Source of Truth

The architecture that resolves all of the above treats the Service Worker as stateless by default and moves the actual state somewhere both contexts can see it without depending on either one staying alive:

  1. Never hold job state only in a module-scope variable. If a value needs to survive past the current event handler, it belongs in storage, not in let.
  2. Persist state transitions, not just final results. Write progress after every meaningful step, not only at completion — a worker killed mid-task should leave behind a resumable checkpoint, not a gap.
  3. Rehydrate the popup from storage on mount. Read current status from storage first; treat any message to the worker as a request to confirm or update that state, never as the only source of truth.
  4. Drive UI updates from chrome.storage.onChanged, not polling or repeated sendMessage round-trips.
  5. Register every listener synchronously at the top level of the worker file — a listener attached inside an async callback may not be registered in time to catch the very wake-up event that revived the worker.

Choosing a Storage Layer

Manifest V3 offers four persistence layers with meaningfully different scope and durability. chrome.storage.session is an in-memory storage API, added in Chrome 102, that’s held by the browser process rather than any single worker instance. It’s the right home for active task state specifically because it survives worker restarts but not browser close: no stale locks carry into a future session, and there’s none of the disk-write overhead .local has. In short, for the exact problem this article is about — that is, task progress that needs to outlive one worker instance but not one browser session — .session is the layer built for it:

LayerSurvives worker restart?Survives browser close?QuotaBest for
chrome.storage.sessionYesNo (cleared)10 MB (1 MB before Chrome 111)Active task/job state, in-progress flags
chrome.storage.localYesYes10 MB (5 MB before Chrome 113); unlimited with the unlimitedStorage permissionLong-term settings, resume logs, caches
chrome.storage.syncYesYes, and syncs across signed-in devices~100 KB total, 8 KB per itemSmall, low-frequency user preferences
IndexedDBYesYesDynamic, tied to available diskLarge datasets, binary data

By default, chrome.storage.session isn’t exposed to content scripts — call chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }) from the worker if a content script genuinely needs to read it. [Source: chrome.storage API reference, Chrome for Developers.]

Building It: A Checkpointed State Machine

Long-running work should be structured as discrete, resumable steps rather than one continuous function — a Finite State Machine the worker advances one checkpoint at a time, writing its position to storage after every step so a fresh worker instance can pick up exactly where the last one died.

// types.ts
export interface TaskProgressState {
  taskId: string | null;
  status: 'IDLE' | 'QUEUED' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
  currentStep: number;
  totalSteps: number;
  lastUpdated: number;
  error?: string;
}

export const INITIAL_TASK_STATE: TaskProgressState = {
  taskId: null,
  status: 'IDLE',
  currentStep: 0,
  totalSteps: 0,
  lastUpdated: 0,
};

export const TASK_STORAGE_KEY = 'primary_task_state';
export const WATCHDOG_ALARM_NAME = 'task-watchdog-alarm';

The worker registers its listeners synchronously at the top level, writes every state transition to chrome.storage.session, and arms a chrome.alarms watchdog that can resume a stalled task if the worker that owned it was killed mid-run:

// background.ts
import {
  TaskProgressState, INITIAL_TASK_STATE, TASK_STORAGE_KEY, WATCHDOG_ALARM_NAME,
} from './types';

// Top-level registration — required so this listener is attached
// synchronously, before any await, on every worker startup.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === 'START_TASK') {
    handleTaskStart(message.totalSteps)
      .then(() => sendResponse({ acknowledged: true }))
      .catch((err) => sendResponse({ acknowledged: false, error: err.message }));
    return true; // Keep the async response channel open — see the traps below.
  }
});

// Watchdog: resumes a task the previous worker instance was killed mid-way through.
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== WATCHDOG_ALARM_NAME) return;
  const { [TASK_STORAGE_KEY]: state } = await chrome.storage.session.get(TASK_STORAGE_KEY);
  if (state?.status === 'PROCESSING' && Date.now() - state.lastUpdated > 45000) {
    executeNextTaskChunk(state.taskId); // No progress in 45s — assume the prior worker died.
  }
});

async function handleTaskStart(totalSteps: number): Promise<void> {
  const taskId = `task_${Date.now()}`;
  const newState: TaskProgressState = {
    taskId, status: 'QUEUED', currentStep: 0, totalSteps, lastUpdated: Date.now(),
  };
  await chrome.storage.session.set({ [TASK_STORAGE_KEY]: newState }); // 1. Commit to the SSOT.
  chrome.alarms.create(WATCHDOG_ALARM_NAME, { periodInMinutes: 0.5 });  // 2. Arm the watchdog.
  executeNextTaskChunk(taskId);                                        // 3. Begin work.
}

async function executeNextTaskChunk(taskId: string): Promise<void> {
  const { [TASK_STORAGE_KEY]: state } = await chrome.storage.session.get(TASK_STORAGE_KEY);
  if (!state || state.taskId !== taskId || state.status === 'COMPLETED') return;

  state.status = 'PROCESSING';
  state.lastUpdated = Date.now();
  await chrome.storage.session.set({ [TASK_STORAGE_KEY]: state });

  try {
    await doOneUnitOfWork(); // Your actual work for this step.
    state.currentStep += 1;
    state.lastUpdated = Date.now();

    if (state.currentStep >= state.totalSteps) {
      state.status = 'COMPLETED';
      await chrome.storage.session.set({ [TASK_STORAGE_KEY]: state });
      chrome.alarms.clear(WATCHDOG_ALARM_NAME);
      return;
    }
    await chrome.storage.session.set({ [TASK_STORAGE_KEY]: state });
    setTimeout(() => executeNextTaskChunk(taskId), 50); // Yield, then continue.
  } catch (err: any) {
    state.status = 'FAILED';
    state.error = err.message;
    await chrome.storage.session.set({ [TASK_STORAGE_KEY]: state });
    chrome.alarms.clear(WATCHDOG_ALARM_NAME);
  }
}

Each state transition — IDLE → QUEUED → PROCESSING → COMPLETED/FAILED, with a SUSPENDED → RECOVERY branch the watchdog alarm handles — commits to storage before the next step runs, so a worker death at any point leaves a resumable cursor instead of a silent gap.

The Reactive Popup

The popup never asks the worker “are you still doing the thing?” It reads current state once on mount, then reacts to chrome.storage.onChanged for everything after that — so a job that started five worker-lifetimes ago still renders correctly the instant the popup opens:

// popup.ts
import { TaskProgressState, INITIAL_TASK_STATE, TASK_STORAGE_KEY } from './types';

class PopupController {
  private startButton: HTMLButtonElement;
  private statusLabel: HTMLElement;

  constructor() {
    this.startButton = document.getElementById('start-btn') as HTMLButtonElement;
    this.statusLabel = document.getElementById('status-label') as HTMLElement;
    this.init();
  }

  private async init(): Promise<void> {
    // 1. Hydrate from the SSOT — never assume a live handshake with the worker.
    const { [TASK_STORAGE_KEY]: current } = await chrome.storage.session.get(TASK_STORAGE_KEY);
    this.render(current ?? INITIAL_TASK_STATE);

    // 2. React to storage changes from any context, including a worker that
    //    didn't even exist when this popup opened.
    chrome.storage.onChanged.addListener((changes, areaName) => {
      if (areaName === 'session' && changes[TASK_STORAGE_KEY]) {
        this.render(changes[TASK_STORAGE_KEY].newValue);
      }
    });

    this.startButton.addEventListener('click', this.onStartClick);
  }

  private onStartClick = async (): Promise<void> => {
    this.startButton.disabled = true;
    chrome.runtime.sendMessage({ type: 'START_TASK', totalSteps: 10 }, (response) => {
      if (chrome.runtime.lastError || !response?.acknowledged) {
        this.startButton.disabled = false; // Worker didn't wake in time — let the user retry.
      }
    });
  };

  private render(state: TaskProgressState): void {
    this.statusLabel.textContent = `Status: ${state.status} (${state.currentStep}/${state.totalSteps})`;
    this.startButton.disabled = state.status === 'PROCESSING' || state.status === 'QUEUED';
  }
}

document.addEventListener('DOMContentLoaded', () => new PopupController());

Notice what this popup never does: it never calls a “get status” message on mount, and it never assumes the worker that started the task is the same instance that’s running it now. Both of those assumptions are exactly what breaks under the lifecycle rules covered above.

Five Traps Behind “Receiving End Does Not Exist”

This single error string maps to several distinct root causes, which is part of why it’s so hard to triage from the message alone. In order of how often they show up in the production issues cited earlier:

  1. A listener that doesn’t return true before its first await. chrome.runtime.onMessage listeners doing async work must call return true synchronously — before any await executes — to keep the response channel open. An await ahead of that return line means Chrome may not consider the listener registered for that message at all, and the response is silently dropped.
  2. port.postMessage() without a try/catch. The worker can be evicted in the gap between checking a port is alive and posting to it — a time-of-check/time-of-use race that only a catch (not a null-check beforehand) actually closes.
  3. Listeners registered inside an async callback instead of at the top level. A listener attached after an await in an async init path may not exist yet when the very event that woke the worker fires — the worker misses its own wake-up trigger.
  4. setTimeout used for “popup closed → clean up” logic. A setTimeout scheduled in the worker is destroyed if the worker is evicted before it fires, silently leaving cleanup undone. Use chrome.runtime.connect() with the port’s onDisconnect event instead — it’s a Chrome-level guarantee tied to the popup’s actual lifetime, not a timer that depends on the worker surviving.
  5. Relying on window.unload/beforeunload in the popup. These are documented as unreliable for popups specifically, because Chrome prioritizes fast popup teardown and may skip firing them. Save state continuously, on every meaningful transition — never only on close.

When Chunking Isn’t Enough: Offscreen Documents and Side Panels

Some work genuinely can’t be split into 30-second chunks — WebRTC signaling, DOM-dependent audio playback, screen capture, clipboard interaction. For that class of problem, the officially sanctioned answer is chrome.offscreen, not a chrome.alarms-based “ping myself every 25 seconds” hack. An Offscreen Document retains a full DOM and window, is not subject to the 30-second idle timer, and can write its own progress to chrome.storage.session while the worker itself stays dormant. Google’s own guidance is explicit that indefinite keep-alive workarounds are an anti-pattern to avoid, not a supported pattern to lean on.

For UI that needs to persist across page navigations within a tab rather than reset on every icon click, chrome.sidePanel is the other structural option worth knowing — it keeps its own context alive across navigations, eliminating the popup’s forced rehydrate-on-every-open cycle entirely.

Troubleshooting

SymptomLikely causeFix
“Could not establish connection. Receiving end does not exist”onMessage listener returned true after an await, or wasn’t registered at the top levelMove return true before any await; register listeners synchronously at module top level
Popup shows “idle” for a task that’s actually still runningPopup queried live worker state instead of reading storage firstRehydrate from chrome.storage.session on mount before sending any message
Extension works in development, fails after publishingTesting was done with chrome://extensions DevTools open, which attaches a keep-alive debugger sessionTest with DevTools closed; verify behavior with chrome.storage.session persistence, not worker liveness
Cleanup logic (e.g., disabling a feature on popup close) silently stops firingCleanup was scheduled with setTimeout in the workerUse chrome.runtime.connect() + the port’s onDisconnect event instead
A periodic health check or poll silently stops runningsetInterval was used instead of chrome.alarmsReplace with chrome.alarms, which is browser-managed and survives worker restarts

Conclusion

The single idea underneath all of this: stop treating the Service Worker as a place to keep state, and start treating it as a place that acts on state kept somewhere else. chrome.storage.session as a Single Source of Truth, checkpointed progress instead of one long-running function, top-level listener registration, and a popup that hydrates from storage before it ever asks the worker anything — together, these four changes are what separate an extension that silently drops work from one that survives its own architecture.

FAQs