C.W.K.
Stream
Lesson 03 of 06 · published

Event-Driven vs Persistent — The Idle Worker

~11 min · service-worker, lifecycle, eviction, events, state-loss

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"The MV3 service worker has only two states from the user's perspective: 'doing something right now' and 'idle, possibly evicted.' Designing for that on/off shape is the entire mental model of Track 2."

The Idle Clock Starts Immediately

The moment your worker's last in-flight event completes, Chrome starts a clock. After about 30 seconds of zero activity — no events firing, no async operations pending, no in-flight Promises — the worker is evicted. Memory freed. Globals gone. Module-level Maps and Sets vanish.

The clock can also expire sooner under memory pressure or if Chrome's internal heuristics decide the worker isn't being useful. Treat 30 seconds as a soft maximum, not a guarantee.

What Wakes a Worker

Any event the worker has a listener registered for. The big ones:

  • chrome.runtime.onMessage — popup, content script, or another extension sent a message
  • chrome.alarms.onAlarm — a scheduled alarm fired (the MV3 replacement for setTimeout/setInterval)
  • chrome.tabs.* events — tab created, updated, removed, activated, replaced
  • chrome.runtime.onInstalled / onStartup — extension installed or Chrome launched
  • chrome.runtime.onConnect — a long-lived port opened from popup or content script
  • chrome.webNavigation.* — page navigation events (requires the webNavigation permission)
  • chrome.contextMenus.onClicked — user activated a registered context menu entry
  • chrome.action.onClicked — user clicked the toolbar icon and no default_popup is set

When any of these fires, Chrome wakes the worker cold: top of background.js runs again, listeners get re-registered (this is critical — the registration is what tells Chrome to dispatch), then Chrome delivers the event.

What Dies on Eviction

Lost when the worker evicts:

  • Module-level variables (let counter = 0, const cache = new Map(), etc.)
  • In-flight Promises (cancelled; their .then chains never fire)
  • WebSocket connections, EventSource connections, MediaStream handles, BroadcastChannel ports
  • Any closure-captured state inside listeners that was set during a previous wake

Survives:

  • chrome.storage.local — written before eviction, readable on next wake
  • chrome.storage.session — added 2023; survives worker eviction but dies with the browser session
  • chrome.alarms — registered alarms persist across worker restarts
  • Registered listeners themselves (Chrome remembers them; re-execution at the top of background.js is how they get re-attached)

The Cold-Wake Pattern

Cold wake means the worker runs from line 1 every time. Treat background.js like a main() function that runs on every event:

  1. Read necessary state from chrome.storage at the top of the relevant handler.
  2. Do the work.
  3. Write any new state back to chrome.storage.
  4. Let the handler return (or resolve its Promise).
  5. Worker becomes idle. Eventually evicted. Wait for the next event.

The cleanest mental model: every event handler is the entire program. Read, work, write, return.

Anti-Patterns to Avoid

Patterns that look fine in MV2 but actively break in MV3:

  • setInterval(fn, 60000) for periodic work — the worker can be evicted before the timer fires. Use chrome.alarms.create({periodInMinutes: 1}) instead; alarms persist across evictions and re-wake the worker.
  • Module-level caches (const userCache = new Map()). They reset on every wake. Use chrome.storage.local with explicit expiry timestamps in the value.
  • Long-lived WebSocket or EventSource holds. The worker eviction kills them. Re-establish per-event if truly needed; or push the connection into a content script in a tab the user keeps open.
  • Globals as "session state" between events. Always treat module-level variables as ephemeral. Even if they happen to survive for a while, you cannot rely on it.
The worker is a function, not a process. Each event handler is one full invocation. State lives in storage, not in scope.
MV2 tutorials saying "keep state in your background.js as a global" are actively wrong for MV3. The worker will evict and that state will vanish — usually at the worst possible moment, when a user is mid-action and the bug is impossible to reproduce. Mistrust any old answer that doesn't mention chrome.storage for state.

Code

Anti-pattern — in-memory counter resets on every worker wake·javascript
// ❌ ANTI-PATTERN — module-level state dies on eviction
let messageCount = 0;

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  messageCount++;
  console.log("[ClipDeck SW] message count:", messageCount);
  sendResponse({ count: messageCount });
  return false;
});
// First few clicks: 1, 2, 3, 4...
// After ~30 seconds idle and a fresh event: 1.
// The variable is gone.
Correct pattern — chrome.storage.local round-trip per event·javascript
// ✅ CORRECT — state lives in chrome.storage, survives eviction
chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => {
  const { messageCount = 0 } = await chrome.storage.local.get("messageCount");
  const newCount = messageCount + 1;
  await chrome.storage.local.set({ messageCount: newCount });
  console.log("[ClipDeck SW] message count:", newCount);
  sendResponse({ count: newCount });
  return true; // async sendResponse — keep channel open
});
// Across evictions: 1, 2, 3, 4, ... persistent.

External links

Exercise

Add the anti-pattern code (module-level messageCount + onMessage listener) to background.js temporarily. In popup.js, append chrome.runtime.sendMessage({type: 'ping'}).then((r) => console.log('SW replied:', r)) so each popup open sends one message. Reload. Open ClipDeck popup several times in a row — counter climbs (1, 2, 3...). Now go to chrome://serviceworker-internals, find ClipDeck, click 'Stop' on it to force eviction. Wait a few seconds. Open the popup again. What's the new count? After confirming the reset, replace the listener with the correct chrome.storage.local pattern from the second code block and repeat the experiment — count should now survive the forced eviction.
Hint
The forced 'Stop' on chrome://serviceworker-internals is the fastest way to demonstrate eviction; the natural ~30-second idle works too but takes longer. If your popup hangs waiting for the SW reply, it usually means the listener returned without calling sendResponse — make sure the anti-pattern listener calls sendResponse synchronously and returns false, and the correct pattern returns true (async) before awaiting storage.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.