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.
Table of Contents
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.
Root cause
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.
Reproducing state loss
Open any MV3 extension with a simple counter and verify the reset yourself:
- Add
let requestCount = 0;at the top level ofbackground.js. - In an
onMessagelistener, incrementrequestCountand log it. - Open
chrome://extensions, click Inspect views → service worker. - Trigger the message three times. You should see
1,2,3in the console. - Wait 35 seconds without triggering any extension event.
- Trigger the message again. The console logs
1, not4.
The worker was evicted at step 5 and re-spawned at step 6. The module scope was re-initialised, resetting requestCount to 0.
Step 1. Understand the eviction window
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:
- A new event arrives and is dispatched.
- An active
MessagePortconnection is open (the worker stays alive as long as a port is connected). - An in-flight
fetchorIndexedDBtransaction is running.
What does not reset the clock:
- A
setIntervalthat was registered previously (it is already dead after the last eviction). - A module-scope
setTimeoutset to fire 60 seconds later. - Polling logic built into a
whileloop.
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.
Step 2. Externalise all state to chrome.storage
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.
Step 3. Hydrate on startup events
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.
Step 4. Migration checklist
Apply this sequence when refactoring an MV2 persistent background page:
- Remove
"persistent": truefrommanifest.json. - Audit every module-scope
let/varthat holds mutable state — each one is a bug waiting to surface. - Replace
setInterval/setTimeoutwithchrome.alarms.create+chrome.alarms.onAlarm. - Move all
addListenercalls to the top level of the script — never insideasyncfunctions or after anawait. - Replace
localStoragewithchrome.storage.local; replacesessionStoragewithchrome.storage.session. - Add a
chrome.runtime.onInstalledhandler that seeds default values in storage so cold starts always have valid data. - Force-terminate the worker via
chrome://extensions → Inspect → Service Workers → Stopand trigger events to verify hydration works.
Cross-browser variation
- Chrome / Edge: Strict ~30 s idle eviction. DevTools shows worker status as running or stopped in the Application panel. Use
chrome://serviceworker-internalsfor low-level diagnostics. - Firefox: MV3 production from Firefox 109. The engine may allow longer idle windows, but do not depend on it — code for non-persistence universally.
browser.runtime.onSuspendfires before eviction; use it to flush pending IndexedDB writes. Thebrowser.*namespace is preferred butchrome.*aliases work. - Safari: Strict eviction matching Chrome. Uses
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.offscreenis unavailable — use content scripts or the scripting API for DOM work.
Verification
- In Chrome, open
chrome://extensionsand click Inspect views → service worker for your extension. - Open the Console tab. Send a message or trigger an event. Confirm your handler runs.
- In the Application tab of DevTools, navigate to Service Workers. Note the worker is listed as running.
- Wait 35 seconds. The worker status changes to stopped.
- Trigger the same event again. The worker restarts and the handler fires. Confirm it reads correct state from
chrome.storage. - Open the Application → Storage → Local Storage panel (or run
chrome.storage.local.get(null)in the service worker console) to inspect persisted values directly.
FAQ
Can I prevent the worker from being evicted?
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.
Firefox still supports "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.
Are 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.
My extension worked fine during development but breaks for users — why?
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.
Related
- How to migrate from Manifest V2 to V3 service workers — manifest changes, API replacements, and step-by-step migration.
- Keeping service workers alive during long tasks — legitimate techniques for extending the active window.
- Up to Service Worker Fundamentals.