Persistent vs Non-Persistent Service Workers Explained
Understand why MV3 service workers are non-persistent, what state loss looks like in practice, and how to design around the ~30-second idle eviction with chrome.storage hydration.
Understand why MV3 service workers are non-persistent, what state loss looks like in practice, and how to design around the ~30-second idle eviction with chrome.storage hydration.
An MV2 extension with "persistent": true kept its background page alive for the entire browser session — every global variable, every open setInterval, every open WebSocket connection stayed put. In MV3 the runtime evicts the service worker after roughly 30 seconds of inactivity, destroying the V8 heap. The next event boots a fresh instance with zero memory of what came before. If your extension stores anything in a module-scope variable and reads it back after that 30-second window, it will read undefined — silently, with no error. This guide is part of Service Worker Fundamentals.
Chrome’s service worker scheduler applies the same lifecycle rules to extension workers that it applies to web service workers: a worker that is not actively handling an event is a candidate for eviction. The browser kills it to reclaim memory and CPU. When an event next fires — a message, an alarm, a tab event — the runtime spawns a new worker instance, re-evaluates the script from top to bottom, and dispatches the event.
Event arrives
↓
Is a worker instance alive?
├─ Yes → dispatch event directly (~few ms)
└─ No → spawn new instance → re-evaluate script → register listeners → dispatch event (~50–200 ms cold-start penalty)
The cold-start penalty is invisible to users for most event types, but it means the state that existed in the previous instance is gone. Global variables reset to their initialisation values; any data structure built up over multiple events starts empty again.
Open any MV3 extension with a simple counter and verify the reset yourself:
let requestCount = 0; at the top level of background.js.onMessage listener, increment requestCount and log it.chrome://extensions, click Inspect views → service worker.1, 2, 3 in the console.1, not 4.The worker was evicted at step 5 and re-spawned at step 6. The module scope was re-initialised, resetting requestCount to 0.
The 30-second clock starts when the last active event handler’s promise chain resolves (or the handler returns synchronously). Any of the following resets the clock:
MessagePort connection is open (the worker stays alive as long as a port is connected).fetch or IndexedDB transaction is running.What does not reset the clock:
setInterval that was registered previously (it is already dead after the last eviction).setTimeout set to fire 60 seconds later.while loop. 1// This approach fails in MV3 — setInterval dies with the worker
2let pollingInterval;
3chrome.runtime.onMessage.addListener(() => {
4 if (!pollingInterval) {
5 pollingInterval = setInterval(pollRemote, 30_000); // lost on eviction
6 }
7});
8
9// Correct replacement — alarm survives eviction
10chrome.runtime.onInstalled.addListener(async () => {
11 const existing = await chrome.alarms.get('remote-poll');
12 if (!existing) {
13 await chrome.alarms.create('remote-poll', { periodInMinutes: 1 });
14 }
15});
16chrome.alarms.onAlarm.addListener((alarm) => {
17 if (alarm.name === 'remote-poll') pollRemote();
18});
Execution context: Extension service worker. chrome.alarms requires the "alarms" manifest permission. The alarm persists in the browser’s scheduler independently of the worker lifecycle and wakes the worker when it fires.
The correct model is: read state from storage at the start of each handler, write mutations back before returning. Treat module-scope variables as cheap per-wake scratch space, not as durable state.
1// background.js — correct non-persistent pattern
2chrome.runtime.onInstalled.addListener(async ({ reason }) => {
3 if (reason === 'install') {
4 // Seed once — this data survives all future evictions
5 await chrome.storage.local.set({ requestCount: 0, schemaVersion: 1 });
6 }
7});
8
9chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
10 if (msg.type === 'INCREMENT') {
11 (async () => {
12 // Hydrate on every invocation — no warm cache assumed
13 const { requestCount = 0 } = await chrome.storage.local.get('requestCount');
14 const next = requestCount + 1;
15 await chrome.storage.local.set({ requestCount: next });
16 sendResponse({ count: next });
17 })();
18 return true; // keep port open for async sendResponse
19 }
20});
Execution context: Extension service worker. chrome.storage.local survives worker eviction, browser restarts, and extension updates (data is deleted only on extension uninstall or explicit .clear() calls). Firefox exposes the identical API under browser.storage.local; both the callback and Promise interfaces work in MV3.
Two events signal worker initialisation: chrome.runtime.onInstalled (install/update only) and chrome.runtime.onStartup (browser profile load). Use these to pre-warm any module-scope cache you maintain for performance — but never trust the cache without a storage read as the fallback.
1// Optional: warm a module-scope cache on each worker boot
2let configCache = null;
3
4chrome.runtime.onStartup.addListener(async () => {
5 configCache = await chrome.storage.local.get(['featureFlags', 'userPrefs']);
6 console.debug('[sw] config cache warmed on startup');
7});
8
9// In handlers: prefer the cache, but always have a storage fallback
10async function getConfig() {
11 if (configCache) return configCache;
12 configCache = await chrome.storage.local.get(['featureFlags', 'userPrefs']);
13 return configCache;
14}
Execution context: Extension service worker. onStartup fires once per browser profile load, not on every worker cold start triggered by an event. Do not use it as a replacement for per-handler storage reads.
Apply this sequence when refactoring an MV2 persistent background page:
"persistent": true from manifest.json.let / var that holds mutable state — each one is a bug waiting to surface.setInterval / setTimeout with chrome.alarms.create + chrome.alarms.onAlarm.addListener calls to the top level of the script — never inside async functions or after an await.localStorage with chrome.storage.local; replace sessionStorage with chrome.storage.session.chrome.runtime.onInstalled handler that seeds default values in storage so cold starts always have valid data.chrome://extensions → Inspect → Service Workers → Stop and trigger events to verify hydration works.chrome://serviceworker-internals for low-level diagnostics.browser.runtime.onSuspend fires before eviction; use it to flush pending IndexedDB writes. The browser.* namespace is preferred but chrome.* aliases work.browser.* namespace (Safari 14+). Alarms can drift under iOS/macOS battery-saving modes; build in a tolerance of a few seconds for time-sensitive tasks. chrome.offscreen is unavailable — use content scripts or the scripting API for DOM work.chrome://extensions and click Inspect views → service worker for your extension.chrome.storage.chrome.storage.local.get(null) in the service worker console) to inspect persisted values directly.No — not for an indefinite period. The browser enforces non-persistence to conserve memory. There are techniques that legitimately extend the active window for specific tasks (open ports, in-flight fetches), but none keep the worker alive permanently. See keeping service workers alive during long tasks for the details.
"persistent": true in MV3 — should I use it?No. Firefox logs a deprecation warning and the flag will be removed. Write for non-persistence now; it is the only cross-browser-compatible model.
chrome.storage.session values kept across worker evictions?Yes. chrome.storage.session survives worker eviction within the same browser session. It is cleared when the browser profile closes (or the user closes all browser windows on platforms where that ends the session). It is not replicated across devices. Use it for data that is too expensive to re-fetch on every handler invocation but does not need to outlast the browser session.
Development builds typically keep the DevTools panel open, which holds a port connection and prevents the worker from being evicted. When DevTools is closed, normal eviction resumes. Always test with DevTools closed and force-stop the worker manually to validate your hydration logic.
Submit MV3 extensions to Chrome Web Store, Firefox AMO and Safari, justify permissions under least-privilege, meet privacy disclosure requirements and avoid common rejection causes.
Inject JavaScript and CSS into web pages with MV3 content scripts — isolated worlds, declarative vs programmatic injection, run_at timing, MAIN world risks, and Shadow DOM UI patterns.
Build MV3-compliant browser extension popups: ephemeral lifecycle, storage hydration, service worker messaging, and cross-browser constraints explained.
Register options_ui or options_page in Manifest V3, persist user preferences through chrome.storage.sync, and keep every extension context in sync via onChanged.