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.

Published April 12, 2026 Updated June 19, 2026 7 min read
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:

  1. Add let requestCount = 0; at the top level of background.js.
  2. In an onMessage listener, increment requestCount and log it.
  3. Open chrome://extensions, click Inspect views → service worker.
  4. Trigger the message three times. You should see 1, 2, 3 in the console.
  5. Wait 35 seconds without triggering any extension event.
  6. Trigger the message again. The console logs 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.

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 MessagePort connection is open (the worker stays alive as long as a port is connected).
  • An in-flight fetch or IndexedDB transaction is running.

What does not reset the clock:

  • A setInterval that was registered previously (it is already dead after the last eviction).
  • A module-scope setTimeout set to fire 60 seconds later.
  • Polling logic built into a 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.

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:

  1. Remove "persistent": true from manifest.json.
  2. Audit every module-scope let / var that holds mutable state — each one is a bug waiting to surface.
  3. Replace setInterval / setTimeout with chrome.alarms.create + chrome.alarms.onAlarm.
  4. Move all addListener calls to the top level of the script — never inside async functions or after an await.
  5. Replace localStorage with chrome.storage.local; replace sessionStorage with chrome.storage.session.
  6. Add a chrome.runtime.onInstalled handler that seeds default values in storage so cold starts always have valid data.
  7. Force-terminate the worker via chrome://extensions → Inspect → Service Workers → Stop and 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-internals for 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.onSuspend fires before eviction; use it to flush pending IndexedDB writes. The browser.* namespace is preferred but chrome.* 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.offscreen is unavailable — use content scripts or the scripting API for DOM work.

Verification

  1. In Chrome, open chrome://extensions and click Inspect views → service worker for your extension.
  2. Open the Console tab. Send a message or trigger an event. Confirm your handler runs.
  3. In the Application tab of DevTools, navigate to Service Workers. Note the worker is listed as running.
  4. Wait 35 seconds. The worker status changes to stopped.
  5. Trigger the same event again. The worker restarts and the handler fires. Confirm it reads correct state from chrome.storage.
  6. 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.

Other MV3 Architecture & Extension Lifecycle Resources