"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 messagechrome.alarms.onAlarm— a scheduled alarm fired (the MV3 replacement forsetTimeout/setInterval)chrome.tabs.*events — tab created, updated, removed, activated, replacedchrome.runtime.onInstalled/onStartup— extension installed or Chrome launchedchrome.runtime.onConnect— a long-lived port opened from popup or content scriptchrome.webNavigation.*— page navigation events (requires the webNavigation permission)chrome.contextMenus.onClicked— user activated a registered context menu entrychrome.action.onClicked— user clicked the toolbar icon and nodefault_popupis 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
.thenchains 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 wakechrome.storage.session— added 2023; survives worker eviction but dies with the browser sessionchrome.alarms— registered alarms persist across worker restarts- Registered listeners themselves (Chrome remembers them; re-execution at the top of
background.jsis 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:
- Read necessary state from
chrome.storageat the top of the relevant handler. - Do the work.
- Write any new state back to
chrome.storage. - Let the handler return (or resolve its Promise).
- 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. Usechrome.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. Usechrome.storage.localwith 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.
chrome.storage for state.