If you migrated an extension from Manifest V2 to V3, you have almost
certainly hit this: your background logic works for a while, then
silently stops. A setTimeout never fires. An in-memory variable is
suddenly undefined. A WebSocket you opened is gone. Nothing throws —
the code just… stops running.
This is not a bug in your extension. It is the single most important behavioral change in Manifest V3, and most of the migration pain traces back to it: the MV3 background is a service worker, and service workers are ephemeral.
The background page is gone
In Manifest V2, the background was a page — a persistent, invisible
DOM document that stayed alive for the entire time your extension was
enabled. You could open a socket in it, keep a big object in memory, set
a setInterval, and trust all of it to still be there an hour later.
MV3 replaces that page with a service worker. Chrome starts the worker in response to an event (a message, an alarm, a navigation, a click on your action), lets it run, and then — once it looks idle — terminates it. The next event spins up a fresh worker with none of the previous one’s memory.
The practical rule of thumb Chrome documents: a service worker is
considered idle and eligible for termination after roughly 30 seconds
with no events, and there is a hard ceiling of about 5 minutes for
a single run even under load. An in-flight fetch or a pending message
response resets the idle timer; a bare setTimeout does not.
So the mental model to internalize:
Your background is not a long-running process. It is a stateless event handler that Chrome wakes on demand and kills the moment it stops looking busy.
Everything below is a consequence of that one sentence.
What breaks, and why
In-memory state disappears
// background.js (MV3) — this is a trap
let requestCount = 0;
chrome.runtime.onMessage.addListener((msg) => {
requestCount++; // resets to 0 every time the worker restarts
console.log(requestCount);
});
requestCount lives in the worker’s memory. When the worker is
terminated and later revived for the next message, it is re-initialized
to 0. The counter never climbs the way you expect. Any state you need
to persist across events must live in chrome.storage, not in a
module-level variable.
Timers don’t survive
// This will very often never fire.
setTimeout(() => doCleanup(), 60_000);
If nothing else keeps the worker alive, Chrome may terminate it long
before 60 seconds elapse, and a terminated worker’s timers die with it.
setTimeout and setInterval are only safe for short delays within a
single event’s active window. For anything longer, you need
chrome.alarms, which is persisted by the browser and re-wakes the
worker when it fires.
Long-lived connections drop
Opening a WebSocket in the service worker and expecting it to stay connected is fighting the platform. The socket dies with the worker. You either need to re-establish it on wake and accept the gaps, use an alarm-driven heartbeat to keep the worker alive during an active session, or move the connection into an offscreen document — each with real tradeoffs.
Top-level listener registration is load-bearing
// WRONG — registered inside an async callback
init().then(() => {
chrome.runtime.onMessage.addListener(handleMessage);
});
When Chrome revives your worker to deliver an event, it re-runs the
top-level of your script and expects the relevant listener to be
registered synchronously during that first tick. If you register
listeners inside a promise, a timeout, or after an await, the event
that woke the worker can arrive before your listener exists — and it is
simply lost. Register every chrome.* event listener at the top level,
synchronously:
// RIGHT — listener exists the moment the worker evaluates
chrome.runtime.onMessage.addListener(handleMessage);
async function handleMessage(msg, sender, sendResponse) {
await ready(); // do async setup *inside* the handler, not around it
// ...
}
The patterns that actually survive
Once you accept that the worker is ephemeral, the surviving patterns are consistent:
-
State goes to
chrome.storage, never module scope. Treat the worker’s memory as a scratchpad valid only for the current event. Read what you need at the start of an event, write back before you finish. -
Register all listeners synchronously at the top level. Do async work inside handlers, never as a precondition for attaching them.
-
Use
chrome.alarmsfor anything longer than a few seconds. Alarms are persisted by the browser and re-wake the worker. The minimum interval is 30 seconds (60 seconds historically, relaxed in recent Chrome), so alarms are for scheduling, not high-frequency polling. -
Keep event handlers idempotent. Because the worker can die mid-task and an event can be redelivered, a handler that runs twice should not corrupt state. Design writes so a repeat is harmless.
-
Persist work queues, don’t hold them in memory. If you’re processing a batch, a queue in a module variable is lost on eviction. A queue in
chrome.storage(or IndexedDB) can be resumed by the next worker that wakes.
The uncomfortable part
None of these patterns are hard individually. The problem is that they are invisible — nothing in the platform stops you from writing the V2 style, and it appears to work in development, where you’re constantly sending events that keep the worker alive. It breaks in production, intermittently, for users you can’t observe, in ways that don’t throw.
That’s why MV3 background code is worth treating as its own discipline: the failure mode is silence, and silence is the hardest thing to debug. The durable-messaging and persistent-queue patterns above aren’t optional polish — under an ephemeral worker they’re the only thing standing between “works on my machine” and “works for users.”
This is part of a series mapping every layer of the browser-extension stack. See also: “Extension context invalidated”, the other half of the extension-lifecycle problem.