Rebuilding In-Memory State After Termination

Design MV3 service worker state that survives termination: storage as source of truth, lazy rehydration with a memoised promise, chrome.storage.session for caches, write-through updates and avoiding startup stampedes.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

The extension keeps a Map of tab states, an in-memory search index, a cached auth token and a counter of blocked requests. Everything works while you test with DevTools open. In real use, the service worker is terminated after thirty idle seconds, and the next event finds every one of those variables empty: the badge resets to zero, the index is rebuilt from scratch on every search, and the auth token is fetched again on each wake-up. The fix is not to keep the worker alive — it is to treat memory as a cache in front of storage, rebuilt cheaply on demand. This guide shows the pattern. It belongs to service worker fundamentals.

Memory is a cache, storage is the truth

A terminated worker loses its JavaScript heap; a new instance starts with fresh module state. Anything that must survive therefore lives in storage, and memory holds a copy for speed. Three storage areas cover the common lifetimes. chrome.storage.session lives in memory in the browser process, survives worker restarts, and is cleared when the browser exits — the right home for per-tab state, caches derived from other data, and short-lived tokens. chrome.storage.local persists across browser restarts for durable state. IndexedDB holds large or structured data. The worker rehydrates its in-memory copy from these the first time an event needs it, and writes changes through to storage as they happen, so a termination at any moment loses nothing.

Where each kind of worker state belongsModule variables as a fast cache, chrome.storage.session for state that should survive worker restarts within a browser session, chrome.storage.local for durable state, and IndexedDB for large structured data.Module variablesfast copylost on terminationstorage.sessionper-tab state, tokens, cachesuntil browser exitstorage.localsettings, cursors, queuesdurableIndexedDBindexes, blobs, large datadurable, large
Every value in memory should be rebuildable from one of the layers below it.

Step-by-step: rehydrate lazily, write through

1. Memoise a single hydration promise

 1// sw.js
 2let tabStateReady = null;
 3const tabState = new Map();
 4
 5function ensureTabState() {
 6  tabStateReady ??= chrome.storage.session.get("tabState").then(({ tabState: saved = {} }) => {
 7    for (const [id, v] of Object.entries(saved)) tabState.set(Number(id), v);
 8  });
 9  return tabStateReady;
10}

Execution context: the service worker. The first caller triggers one storage read; concurrent callers during startup await the same promise instead of each reading storage — avoiding a stampede of reads when a burst of events wakes the worker. After termination, the module re-evaluates, tabStateReady is null again, and the next event rehydrates.

2. Await hydration in every handler that reads state

1chrome.tabs.onUpdated.addListener(async (tabId, change) => {
2  if (!change.status) return;
3  await ensureTabState();
4  const s = tabState.get(tabId) ?? { loads: 0 };
5  s.loads += change.status === "complete" ? 1 : 0;
6  tabState.set(tabId, s);
7  persistTabState();
8});

Execution context: the service worker, with the listener registered synchronously at the top level and hydration awaited inside it. Registering first and awaiting inside the handler keeps both rules: no event is missed, and no handler reads empty state. Reading state before hydration completes is the bug this pattern prevents.

Wake-up with lazy rehydrationA tab event wakes a fresh worker; the handler awaits ensureTabState, which reads storage.session once; the handler updates the map and schedules a write-through; a second event in the same burst reuses the hydrated map.ChromeWorkerstorage.sessiontabs.onUpdated (worker cold)get('tabState') — oncesaved mapupdate maptabs.onUpdated (warm)debounced set('tabState')
One read per worker lifetime, one write per change burst.

3. Write through, debounced

1let persistTimer = null;
2function persistTabState() {
3  clearTimeout(persistTimer);
4  persistTimer = setTimeout(() => {
5    chrome.storage.session.set({ tabState: Object.fromEntries(tabState) });
6  }, 200);
7}

Execution context: the service worker. A short debounce turns a burst of updates into one write; the pending events that caused the burst keep the worker alive long enough for the timer to fire. If the worker were terminated inside the 200 ms window, at most that window’s changes are lost — acceptable for derived state like counters. For state that must never be lost, write immediately.

Rehydration strategies comparedEager load at startup, lazy memoised load on first use, and per-call storage reads compared on cold-start cost, read count and correctness under termination.StrategyCold-start costStorage readsTermination-safeEager load at top levelAlways paidOneYes, if awaitedLazy memoisedOnly when neededOneYesRead storage every callNoneManyYesMemory onlyNoneNoneNo
Lazy memoised hydration costs nothing until needed and reads once.

4. Rebuild expensive derived state from compact inputs

1let indexPromise = null;
2export function getIndex() {
3  indexPromise ??= (async () => {
4    const { items = [] } = await chrome.storage.local.get("items");
5    return buildIndex(items);                       // derived, not stored
6  })();
7  return indexPromise;
8}
9chrome.storage.onChanged.addListener((c, area) => { if (area === "local" && c.items) indexPromise = null; });

Execution context: the service worker. Some state is cheaper to rebuild than to store — a search index built from a few thousand items takes milliseconds. Store the inputs, derive the structure on first use, and invalidate the memo when the inputs change. If rebuilding is slow, store a serialised version in IndexedDB and load it instead.

5. Cache tokens with their expiry in session storage

1export async function getToken() {
2  const { token } = await chrome.storage.session.get("token");
3  if (token && token.exp > Date.now() + 30_000) return token.value;
4  const fresh = await refreshToken();
5  await chrome.storage.session.set({ token: { value: fresh.access_token, exp: Date.now() + fresh.expires_in * 1000 } });
6  return fresh.access_token;
7}

Execution context: the service worker. A token kept only in a variable is re-fetched after every termination, which can mean dozens of refreshes a day and rate limits. Session storage keeps it for the browser session without writing it to disk. See refreshing and storing access tokens securely.

6. Clean up state for things that no longer exist

1chrome.tabs.onRemoved.addListener(async (tabId) => {
2  await ensureTabState();
3  tabState.delete(tabId);
4  persistTabState();
5});

Execution context: the service worker. Per-tab state accumulates without cleanup, and session storage has a 10 MB limit. Remove entries when tabs close, and on startup prune entries for tab ids that no longer exist (tab ids are not reused within a session, but state from a crashed session should not linger in local).

7. Make it observable

During development, log hydration ([state] hydrated tabState: 42 entries in 3 ms). It makes termination visible — you see the log each time a fresh worker starts — and confirms the hydration cost stays small. A hydration log on every single event means something keeps resetting the memo.

Common mistakes

  • Reading state before hydration resolves. Handlers see empty maps.
  • Hydrating after an await at top level. Listeners register too late.
  • No write-through. Termination loses every change since the last save.
  • Storing derived state that is cheap to rebuild. Wasted storage and migration burden.
  • Keeping the worker alive instead. Treats the symptom; costs resources.

Cross-browser variation

  • Chrome / Edge: storage.session with a 10 MB quota; the pattern as described.
  • Firefox: storage.session from 115; the event-page background is suspended less often, which can hide missing hydration — test with forced suspension.
  • Safari: storage.session from 16.4; aggressive suspension makes write-through especially important.

Verification

  1. Generate state (open tabs, trigger events), stop the worker in chrome://serviceworker-internals, trigger another event, and confirm state is intact.
  2. Confirm the hydration log appears once per worker instance.
  3. Fire 50 events in a burst on a cold worker and confirm one storage read and one write.
  4. Close tabs and confirm their entries disappear from session storage.

FAQ

Should I hydrate everything at startup?

Only what nearly every event needs. Lazy hydration keeps cold starts fast for events that need nothing.

Is chrome.storage.session fast enough for hot paths?

Reads take around a millisecond; that is why memory still caches it. Read once per worker lifetime.

Can I store class instances?

Storage serialises to JSON-like data. Store plain objects and reconstruct instances after hydration.

How do I test rehydration automatically?

In an end-to-end test, populate state, stop the worker through the DevTools Protocol (ServiceWorker.stopWorker) or by waiting past the idle timeout with no extension pages open, then trigger an event and assert that the result reflects the earlier state. A unit test can simulate the same by resetting the module and calling a handler.

Should the popup read state from the worker or from storage?

From storage. The popup does not need to wake the worker for state it can read directly, and it sees the same source of truth the worker rehydrates from.

Other MV3 Architecture & Extension Lifecycle Resources