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 fromchrome.storage.onChangedinstead of live message round-trips.- The most common trigger of “Could not establish connection. Receiving end does not exist” is a mechanical one — an
onMessagelistener that doesn’t returntruebefore its firstawait— 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:
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 trigger | Chrome version | What it actually does |
|---|---|---|
| WebSocket activity | 116+ | Active traffic on an open WebSocket resets the idle timer |
chrome.debugger session | 118+ | An attached debugger session prevents termination outright |
chrome.runtime.connectNative() | 105+ | Keeps the worker alive until the native host disconnects |
| Offscreen document messages | 109+ | Messages received from an offscreen document reset the timer |
| Long-lived port messages | 114+ | 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:
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:
A sample of the exact-match and maintainer-confirmed reports, in production code:
| Project | What happened | Resolution |
|---|---|---|
| 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 refresh | Filed 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 listening | Root 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 on | Merged 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 state | Multiple 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 instance | Fixed 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:
- 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. - 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.
- 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.
- Drive UI updates from
chrome.storage.onChanged, not polling or repeatedsendMessageround-trips. - 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:
| Layer | Survives worker restart? | Survives browser close? | Quota | Best for |
|---|---|---|---|---|
chrome.storage.session | Yes | No (cleared) | 10 MB (1 MB before Chrome 111) | Active task/job state, in-progress flags |
chrome.storage.local | Yes | Yes | 10 MB (5 MB before Chrome 113); unlimited with the unlimitedStorage permission | Long-term settings, resume logs, caches |
chrome.storage.sync | Yes | Yes, and syncs across signed-in devices | ~100 KB total, 8 KB per item | Small, low-frequency user preferences |
| IndexedDB | Yes | Yes | Dynamic, tied to available disk | Large 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:
- A listener that doesn’t return
truebefore its firstawait.chrome.runtime.onMessagelisteners doing async work must callreturn truesynchronously — before anyawaitexecutes — to keep the response channel open. Anawaitahead of that return line means Chrome may not consider the listener registered for that message at all, and the response is silently dropped. port.postMessage()without atry/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 acatch(not a null-check beforehand) actually closes.- Listeners registered inside an async callback instead of at the top level. A listener attached after an
awaitin 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. setTimeoutused for “popup closed → clean up” logic. AsetTimeoutscheduled in the worker is destroyed if the worker is evicted before it fires, silently leaving cleanup undone. Usechrome.runtime.connect()with the port’sonDisconnectevent instead — it’s a Chrome-level guarantee tied to the popup’s actual lifetime, not a timer that depends on the worker surviving.- Relying on
window.unload/beforeunloadin 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
| Symptom | Likely cause | Fix |
|---|---|---|
| “Could not establish connection. Receiving end does not exist” | onMessage listener returned true after an await, or wasn’t registered at the top level | Move return true before any await; register listeners synchronously at module top level |
| Popup shows “idle” for a task that’s actually still running | Popup queried live worker state instead of reading storage first | Rehydrate from chrome.storage.session on mount before sending any message |
| Extension works in development, fails after publishing | Testing was done with chrome://extensions DevTools open, which attaches a keep-alive debugger session | Test 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 firing | Cleanup was scheduled with setTimeout in the worker | Use chrome.runtime.connect() + the port’s onDisconnect event instead |
| A periodic health check or poll silently stops running | setInterval was used instead of chrome.alarms | Replace 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
chrome.debugger session (Chrome 118+) is a documented keep-alive trigger, so the 30-second idle timer never fires while you’re watching. Close DevTools and retest — that’s the environment your real users are in. .local persists across browser restarts, so a task interrupted by a crash or force-quit can leave a stale “in progress” record that outlives the session that created it. .session is purpose-built for this — it survives worker restarts but clears on browser close, so stalled locks don’t carry forward. chrome.offscreen is Google’s sanctioned mechanism for continuous work that genuinely can’t be chunked — it has a real DOM and isn’t subject to the idle timer at all. An alarms-based self-ping to avoid idle termination is explicitly discouraged in Chrome’s own guidance as an anti-pattern, even though it may work in the short term. Related Citations & References
- STBest Chrome Extension Boilerplates in 2026 — StarterPick Guides | StarterPick
- BLChrome Extension Manifest V3 Explained
- RIRight Tail | Software Development, Product Development, UI/UX Design & QA
- AR2507.13926
- DEThe extension service worker lifecycle | Chrome for Developers
- OTchrome-extension — Agent Skill | ottermind
- REReddit
- MEHow to Create a Service Worker for Chrome Extensions
- ISChromium
- F2How To Develop a Screen Recorder Chrome Extension – F22 Labs
- ISChromium
- GIcreate-chrome-extension/docs/03-cws-best-practices.md at main · codyhxyz/create-chrome-extension · GitHub
- REReddit
- PLWhy Chrome Extensions Fail in Manifest V3 (2026 Guide) | PlugThis
- ISChromium
- ISChromium
- ZEService Worker Security Best Practices – 2024 Guide
- DEMigrate to a service worker | Chrome for Developers
- STService Worker Not Listening To The Action When The Toolbar Icon Is Clicked Ch
- GIchrome.action.onClicked does not fire when ServiceWorker is inactive · Issue #2590 · GoogleChrome/developer.chrome.com · GitHub
- ISChromium
- ISChromium
- GIKoalaSync/docs/CHANGELOG.md at main · Shik3i/KoalaSync · GitHub
- DEBuilding a Chrome Extension Using React and Vite: Part 2 – State Management and Message Passing – DEV Community
- STChrome Extension Mv3 Migration How To Convert Background Script That Uses Wind
- DEchrome.storage | Reference | Chrome for Developers
- DELocal vs Sync vs Session: Which Chrome Extension Storage Should You Use? – DEV Community
- REData Storage Options in Chrome Extensions: localStorage vs. chrome.storage
- REEffective State Management in Chrome Extensions
- DEbrowser.storage | API | Chrome for Developers
- MAPrivacy Policy — Markdown Web Clipper
- STChrome Storage Onchanged Between Extensions Background And Popup
- GIGitHub – aegisgatesecurity/aegisgate-lens: 🛡️ AegisGate Lens v0.4.1 — privacy-first browser extension that detects PII, secrets, XSS, compliance risks, and adversarial prompt injections in prompts to 10 AI chat tools. 155+ regex patterns + Char CNN-BiLSTM ML detection. 100% on-device. Zero prompt data ever leaves your browser. Free, forever. Apache 2.0. · GitHub
- MOFix "Access to storage is not allowed from this context" in a Chrome extension · Moderok
- STNot Able To Access Storage In Content Scripts In Chrome Extension Manifest V3
- NPUse Chrome Storage
- GIGitHub – asdfghj1237890/WebVideo2NAS: Self-hosted Chrome → NAS pipeline: capture HLS / DASH / MP4 streams from any site and download to your NAS. Single multi-arch Docker image (FastAPI + worker + ffmpeg) distributed via GHCR. · GitHub
- GIdev-break-enforcer/README.md at main · sudipto68/dev-break-enforcer · GitHub
- DEchrome.offscreen | API | Chrome for Developers
- DEOffscreen Documents in Manifest V3 | Blog | Chrome for Developers
- DEUse geolocation | Chrome Extensions | Chrome for Developers
- MEContexa Building A Privacy First Ai Session Workspace With Chromes Built In Ai On Device 0daddf97cf66
- CHChrome Tab Organizer & Productivity Tool – Chrome Web Store




