Service worker terminated: why your Chrome extension loses its memory when it sleeps

The counter resets to zero. The toolbar badge shows a number from an hour ago. The message port your popup opened is gone without an error, and the feed refresh you scheduled with setTimeout has not fired once in production. When a service worker is terminated, a Chrome extension loses every module-level variable, open port, and pending timer with it. That is not a bug. That is Manifest V3 working as designed.

Manifest V3 abolished persistent background pages in favor of a single event-driven service worker that Chrome terminates after roughly 30 seconds of idleness. Nothing keeps it alive indefinitely, and the extensions that feel this hardest are the ones with no server to catch them. A local-first tool like Yougroup, which keeps its YouTube lists, watch marks, and queue state entirely in Chrome extension storage, is exactly this shape: when the worker recycles, everything must survive client-side or not at all.

The mental model that fixes everything downstream: stop treating the service worker as an app shell. Treat it as a stateless function handler. Memory is a cache; chrome.storage is the database. The rest of this article covers the manifest v3 service worker lifecycle, the failure patterns it creates, and four durable fixes with code.

The manifest v3 service worker lifecycle: what terminated actually means

Chrome's lifecycle documentation states the termination rules directly:

Developer leaning back from an open laptop at a dusk desk, hands resting in their lap while watching a brass sand hourglass drain beside the keyboard, seen from behind with the screen turned away from the camera.
About thirty seconds after the last event or API call, the idle clock runs out and the worker is gone.

About thirty seconds after the last event or API call, the idle clock runs out and the worker is gone.

"Normally, Chrome terminates a service worker when one of the following conditions is met: After 30 seconds of inactivity. Receiving an event or calling an extension API resets this timer. When a single request, such as an event or API call, takes longer than 5 minutes to process. When a fetch() response takes more than 30 seconds to arrive."

Every dispatched event and every extension API call resets the 30-second timer, but nothing resets it forever, so an idle worker always dies eventually. A busy worker is bounded as well: the other conditions in that list can end it in the middle of a request.

The migration changed the manifest too. background.scripts, an array, becomes background.service_worker, a single string optionally paired with "type": "module", and background.persistent is removed entirely. There is no persistent option left to set. The worker also runs off the main thread, so it has no access to the DOM or window; anything that touches document has to move into an offscreen document or another API (Chrome migration guide).

Chrome wakes the worker only by dispatching an event: onInstalled, onStartup, runtime.onMessage, tabs and webNavigation events, or an alarm firing. Between wakeups, everything inside the worker is ephemeral by design: module-level variables, Maps, counters, open MessageChannel ports, and any pending setTimeout or setInterval.

The failure patterns, catalogued

Each symptom below maps to one lifecycle cause, so you can pattern-match your own bug before reading the fixes.

Symptom

What actually happened

Lost counters and cached Maps

Globals set during one wake window were dropped at shutdown; the next event starts a fresh script

Dead message ports

An open MessageChannel dies with the worker, silently breaking popup-to-worker communication

Timers that never fire

A setTimeout or setInterval scheduled in one window was cleared by termination before it ran

Stale badges and inconsistent UI

The badge was computed from in-memory state that no longer matches anything after a recycle

Data that drifts instead of crashing

Feed snapshots, dedup indexes, and queue state slowly diverge because regeneration depends on memory

The last row is the one that ships. A worker that loses a counter fails loudly in testing. An extension that regenerates a feed snapshot or dedup index from memory instead of storage keeps working while its outputs quietly diverge from reality, which is far more expensive to debug. If your tool surfaces YouTube uploads, a missed or partial refresh stays invisible until a video goes missing from the user's feed.

The compounding trap: these bugs often reproduce only in production, because opening the service worker in DevTools keeps it alive. The bug waits until you are not looking.

Fix 1: chrome.storage is your source of truth

Durable chrome extension state persistence comes down to one rule: write state through to chrome.storage.local on every mutation, and hydrate it on every wake. Memory becomes a cache in front of storage, and the worker can die at any moment without losing anything.

Split storage into two tiers. chrome.storage.local holds durable user data: lists, watch marks, queue state. chrome.storage.session is the underrated middle tier. It is cleared when the browser closes but survives service worker restarts within a session, which makes it the right home for regenerable caches such as feed snapshots and dedup indexes. It is capped at 10 MB, so per-tab state needs explicit cleanup.

Memoize hydration as a single promise so concurrent event handlers do not trigger duplicate loads, and guard first-time initialization with a stored flag:

let state;
let initPromise;

function init() {
if (!initPromise) {
initPromise = chrome.storage.local
.get(['appState', 'initialized'])
.then(({ appState, initialized }) => {
state = initialized ? appState : defaultState();
if (!initialized) {
return chrome.storage.local.set({ appState: state, initialized: true });
}
});
}
return initPromise;
}

async function commit() {
await chrome.storage.local.set({ appState: state });
}

async function markWatched(videoId) {
await init();
if (!state.watched.includes(videoId)) {
state.watched.push(videoId);
await commit(); // write through before returning
}
}

Pair writes with chrome.storage.onChanged listeners so the popup and the worker stay consistent without long-lived ports: the popup re-renders on change, the worker recomputes the badge. There is no port left to lose.

Two escape hatches when storage is the wrong shape. Chrome's guidance recommends IndexedDB for structured data and CacheStorage only for Request/Response pairs when proxying fetch requests. Extension storage also holds JSON objects under your own keys, which means it survives when the user clears their web cache.

Fix 2: replace timers with chrome.alarms in the background script

The migration guide is blunt about timers: "Since they terminate when not in use, you'll need to persist application states rather than rely on global variables. Terminating service workers can also end timers before they have completed. You'll need to replace them with alarms."

Brass twin-bell alarm clock standing between a mechanical keyboard and a closed laptop on a wooden desk in soft morning window light.
An alarm is a wake event: it starts the worker again on schedule instead of dying with the previous wake window.

An alarm is a wake event: it starts the worker again on schedule instead of dying with the previous wake window.

The reason alarms work where timers fail is that an alarm firing is a wake event. A setInterval scheduled during one wake window is cleared by termination before it runs; a chrome.alarms alarm wakes the worker up on schedule. Any periodic work, an RSS feed refresh, a badge update, a queue reconciliation, must be scheduled with chrome.alarms.create. setInterval in a service worker is an anti-pattern except for sub-second work inside a single wake window.

const ALARM = 'feed-refresh';

async function ensureAlarm() {
const alarm = await chrome.alarms.get(ALARM);
if (!alarm) {
// 0.5 minutes = the 30 second minimum, available since Chrome 120
await chrome.alarms.create(ALARM, { periodInMinutes: 0.5 });
}
}

chrome.alarms.onAlarm.addListener(async (alarm) => {
await init();
if (alarm.name === ALARM) {
await refreshFeeds();
await ensureAlarm(); // re-arm, in case the alarm was cleared
}
});

Respect the constraints. The minimum period is 30 seconds in modern Chrome and 1 minute in older versions, and an alarm handler gets roughly 30 seconds of runtime, so batch the work or chain it to another alarm. Most importantly, re-verify the alarm on every worker start. Alarms have historically been cleared when the extension reloads or the browser restarts; Chrome 150 added a persistAcrossSessions flag that defaults to true and persists until the extension updates, but it is unsupported in other browsers and in pre-150 Chrome. Checking on start remains the cross-browser pattern.

Fix 3: offscreen documents when 30 seconds is not enough

Some work cannot fit the idle window or needs APIs the worker does not have: parsing HTML with DOMParser, hosting a dedicated Web Worker, playing audio. The chrome.offscreen API exists for exactly this. The worker spins up a document on demand, delegates the job, and tears the document down when idle.

await chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: ['DOM_PARSER'],
justification: 'Parse RSS payloads without a window'
});

The rules are strict. Only one offscreen document may exist at a time, and it must declare a reason from a fixed enum such as DOM_PARSER or WORKERS. Since Chrome 109, messages from an offscreen document reset the worker's idle timer, so an active offscreen job keeps its coordinator alive for the duration.

An offscreen document does not replace storage. It buys execution time and DOM-adjacent APIs, not persistence, so the result still has to be committed through the pattern in Fix 1. The practical case for a YouTube curation tool is delegating HTML parsing of feed payloads to a DOM_PARSER document so the worker never touches window.

Re-registration on wake: top-level listeners and the onStartup trap

Listeners must be registered synchronously at the top level of the worker script. When an event wakes the worker, Chrome runs the script's first synchronous pass and then dispatches the waking event to whatever was registered by then (lifetime rules explained). A listener added inside a promise callback or after an await is not guaranteed to be registered in time, and the event can be missed entirely.

The second trap is chrome.runtime.onStartup. It does not fire on browser crash or restore, and it does not fire on every wake. Hydration logic cannot live there alone.

One pattern fixes both: a shared, idempotent init() called from every event handler, guarded by the storage flag from Fix 1.

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
init().then(() => handleMessage(msg, sender, sendResponse));
return true; // keep the channel open for the async reply
});

chrome.runtime.onInstalled.addListener(() => init());
chrome.runtime.onStartup.addListener(() => init());
ensureAlarm(); // runs during the first synchronous pass

Every handler hydrates before it touches state, so it does not matter which event woke the worker.

How keep-alive rules changed from Chrome 105 to 120

The lifetime rules changed repeatedly across this range, so the same background code can behave differently depending on which Chrome version runs it. The documented changes:

Chrome

Behavior change

105

Ports opened with connectNative() keep the worker alive

109

Messages from an offscreen document reset the idle timer

110

Extension API calls reset the timer, not just event dispatch

114

Long-lived port messaging keeps the worker alive, but merely opening a port no longer resets timers

116

Active WebSocket send/receive extends lifetimes; prompt-showing APIs such as permissions.request and identity.launchWebAuthFlow may exceed 5 minutes

118

Active debugger sessions keep the worker alive

120

Alarms gain a 30-second minimum period to match the service worker lifecycle

Rely on these behaviors opportunistically. Design as if the worker can die at any moment, because it can.

Why the bug disappears in DevTools

Opening the service worker in DevTools keeps it active, so lifetime bugs only appear with DevTools closed. That is why the bug that plagued your users for weeks vanishes the moment you try to reproduce it.

Developer sitting with arms crossed at a tidy desk beside a closed laptop, glancing at a round wall clock while deliberately waiting out the idle period.
The reliable reproduction protocol: tools closed, wait for the inactive state, then wake the extension and check what survived.

The reliable reproduction protocol: tools closed, wait for the inactive state, then wake the extension and check what survived.

Use chrome://extensions to observe real termination instead. The worker's entry shows as "service worker (inactive)" roughly 30 seconds after it stops being used. A workable protocol: close DevTools, wait for the inactive state, then trigger the extension and verify that state survived the wake. Check that hydration, alarm re-creation, and listener registration all behave identically after a cold wake. This is the point of the durable pattern: memory as a cache in front of storage works even when you cannot watch the worker die.

Design for amnesia

The architectural reframe is to assume the worker forgets everything between events, then make that assumption harmless:

This is the architecture Yougroup ships. Lists, watch marks, and queue state live in extension storage, refreshes run on alarms, and the worker is free to die between them. A terminated service worker then costs at most a cache rebuild the user never sees. Design for amnesia, and termination stops being a bug report.