← Blog

Why your MV3 extension keeps dying after 30 seconds

The Manifest V3 service worker is not a background page — it's ephemeral by design. Here's exactly when Chrome kills it, why your timers and state vanish, and the patterns that survive eviction.

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:

  1. 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.

  2. Register all listeners synchronously at the top level. Do async work inside handlers, never as a precondition for attaching them.

  3. Use chrome.alarms for 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.

  4. 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.

  5. 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.

Building this for real?

Exo is an application framework for browser-native software — the patterns in this post are the ones it paves over. It's in alpha and we're onboarding founding partners.

Join the Alpha More posts