You open chrome://extensions, click into your extension, and the service worker says "Inactive". Your setInterval stopped firing. A message arrives and nothing responds. If you've migrated to Manifest V3 and your background logic keeps mysteriously dying — this is by design, not a bug. Here is exactly what's happening and how to fix it properly.
It's not a bug — it's by design
In Manifest V2, an extension had a persistent background page — a hidden tab that stayed alive for the entire browser session. Your timers ran, your in-memory variables persisted, your code just kept executing.
Manifest V3 replaced that with a service worker. Service workers are designed to be short-lived and event-driven. Chrome spins one up when an event your extension registered for fires, lets it run, and then terminates it once it goes idle. This is intentional — it's how MV3 reduces the memory and battery cost of having dozens of extensions installed.
So when your Chrome extension service worker keeps stopping, nothing is broken. The platform is doing exactly what it promised. The bug is in code that assumed the MV2 persistence model and never got rewritten for the new lifecycle.
When exactly Chrome stops the worker
Chrome terminates the service worker when it has been idle. The practical rules:
- ~30 seconds of inactivity. If no event fires and no extension API call is in flight, the worker is stopped after roughly 30 seconds.
- The 5-minute hard cap is gone. Older guidance mentioned a fixed 5-minute lifetime. Modern Chrome resets the idle timer on every event and every API response — a worker that stays busy can live much longer. But the moment it goes quiet, the 30-second clock starts.
- A single long-running task does not keep it alive. An
awaiton a slowfetchkeeps the worker up while the request is pending, but asetTimeoutfor 60 seconds does not — the worker is considered idle and gets killed before the callback runs. - It restarts on the next event. When a new event arrives, Chrome re-evaluates your service worker script from scratch. Every top-level variable is reinitialised. Everything you stored in memory is gone.
That last point is the one that bites hardest. The worker doesn't "resume" — it is a fresh execution. Treat every wake-up as a cold start.
The wrong fixes (keep-alive hacks)
Search this problem and you'll find a dozen tricks to force the worker to stay alive: pinging yourself with chrome.runtime.getPlatformInfo() every 20 seconds, opening a dummy chrome.runtime.connect port, recursive setTimeout loops. They mostly work. They are also the wrong answer for almost every case.
- You're fighting the platform. Each Chrome release tightens the idle rules. A hack that works today can silently stop working after an update — and you won't notice until users complain.
- It defeats the point of MV3. A permanently-alive worker is just an MV2 background page with extra steps. It burns the user's memory and battery — exactly what MV3 set out to stop.
- It hides the real bug. If your code only works when the worker never dies, it will break the first time it does — on a slow machine, after an OS sleep, on the next Chrome version. Keep-alive hacks paper over a design flaw instead of fixing it.
There is exactly one situation where keeping the worker alive is legitimate, and we cover it in Fix 4. Everything else should be rewritten to be lifecycle-correct.
The right mental model
Stop thinking of the service worker as "my background process." Think of it as a stateless function that Chrome invokes on events. Three rules follow from that:
- Anything that must outlive a single event goes into
chrome.storage, not a variable. - Anything time-based uses
chrome.alarms, which can wake a dead worker, notsetTimeout/setInterval, which die with it. - Every event listener is registered at the top level, synchronously, so it exists the instant Chrome re-evaluates the script.
Rewrite against those three rules and the "keeps stopping" problem disappears — because you no longer care whether it stops.
Fix 1 — register listeners synchronously
When an event fires for a sleeping worker, Chrome restarts the script and then dispatches the event. If your listener registration sits behind an await, the worker may have already moved past that line — and Chrome decides there is no listener, so the event is dropped.
// background.js — service worker
// ❌ Wrong: listener registered after an await.
// On a cold start, the onInstalled event can be missed entirely.
const settings = await chrome.storage.local.get('settings');
chrome.runtime.onInstalled.addListener(handleInstall);
// ✅ Right: every listener registered at the top level, synchronously.
chrome.runtime.onInstalled.addListener(handleInstall);
chrome.runtime.onMessage.addListener(handleMessage);
chrome.alarms.onAlarm.addListener(handleAlarm);
// Async work happens INSIDE the handlers, never before registration.
async function handleInstall(details) {
const settings = await chrome.storage.local.get('settings');
// …
}Rule of thumb: the first thing your service worker file does, every time, is register every listener. No imports that await, no config fetches, no storage reads before that block.
Fix 2 — never keep state in memory
A counter, a queue, a cache, a "is the user logged in" flag — if it lives in a top-level variable, it is gone the moment the worker sleeps. The next event sees a fresh, empty value.
// ❌ Wrong: in-memory counter resets to 0 on every worker restart.
let eventCount = 0;
chrome.runtime.onMessage.addListener(() => {
eventCount++; // almost always 1
});
// ✅ Right: state lives in chrome.storage.local, survives termination.
chrome.runtime.onMessage.addListener(async () => {
const { eventCount = 0 } = await chrome.storage.local.get('eventCount');
await chrome.storage.local.set({ eventCount: eventCount + 1 });
});chrome.storage.local is asynchronous and survives worker termination, browser restarts, and updates. It is the only safe place for anything stateful. Treat top-level variables as a scratchpad valid for the current event only.
Fix 3 — chrome.alarms, not setInterval
This is the single most common cause of "my background script stopped running." You set up a setInterval to poll an API or flush a queue every minute. It works for 30 seconds, then the worker goes idle, the interval dies with it, and the callback never fires again.
// ❌ Wrong: setInterval dies when the worker is terminated.
setInterval(() => syncData(), 60_000);
// ✅ Right: chrome.alarms wakes the worker on schedule.
chrome.alarms.create('sync', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') syncData();
});chrome.alarms is the correct primitive: it persists at the browser level, and when an alarm fires Chrome restarts the service worker specifically to run your handler. One caveat — the minimum interval Chrome honours reliably is around 30 seconds (periodInMinutes: 0.5). If you need sub-second timing, you need a different design; alarms are for periodic work, not precision timers.
Fix 4 — the legitimate keep-alive
There is one case where keeping the worker alive is correct rather than a hack: when you genuinely have a long-running connection that must not drop — most commonly a chrome.runtime.connectNative port to a native messaging host, or an active WebSocket that the user is depending on right now.
In that case, Chrome already keeps the worker alive for the lifetime of the port. You don't need a hack — you need to hold the port open only while the work is actually happening, and let it close (and the worker sleep) the moment it's done.
// Legitimate: the worker stays alive only while this port is open.
const port = chrome.runtime.connectNative('com.example.host');
port.onMessage.addListener(handleNativeMessage);
port.onDisconnect.addListener(() => {
// Port closed — let the worker go idle and be terminated. Good.
});
// When the task is finished, close it explicitly:
function finish() {
port.disconnect();
}The distinction: keeping the worker alive because real work is in progress is fine — that's the platform working as intended. Keeping it alive just so your stale architecture keeps working is the anti-pattern.
How this silently breaks analytics
Here is the failure mode almost nobody catches in testing. You add analytics — your own, or a generic SDK. It batches events in memory and flushes them every 30 seconds with setInterval. In development, with DevTools open, the worker never sleeps, so it works perfectly.
In production, with DevTools closed, the worker sleeps after every burst of activity. The in-memory batch is discarded. The setInterval flush never runs. You ship, the dashboard shows a fraction of real usage, and you have no idea your numbers are wrong — because the events were never sent, so nothing errors.
An analytics SDK that's correct for MV3 has to do all four fixes above: register its listeners synchronously, persist its event queue to chrome.storage, flush via chrome.alarms, and add a fast-path flush on chrome.runtime.onSuspend to catch the worker just before it sleeps. This is exactly what we built the Crxlytics SDK to handle — events survive worker termination instead of vanishing with it.
If you're evaluating analytics tools, the next thing to read is why the obvious choice — Google Analytics — breaks for extensions for this exact reason and several others. And for the full picture of what to measure once tracking is reliable, see our guide to Chrome extension metrics.
FAQ
Why does my Chrome extension service worker say "Inactive"?
Because it has gone idle and Chrome terminated it — this is normal MV3 behaviour, not an error. It will restart automatically the next time an event it registered for fires. "Inactive" only indicates a problem if events that should wake it (a message, an alarm, a click) are not waking it — which usually means the listener wasn't registered synchronously.
How do I keep a Manifest V3 service worker alive permanently?
You shouldn't. The supported answer is to rewrite the code so it doesn't need to stay alive — persist state to chrome.storage, schedule work with chrome.alarms. The only legitimate long-lived case is an active native-messaging or WebSocket connection, and Chrome keeps the worker alive for that on its own.
Why does my setInterval stop working in a Chrome extension?
setInterval and setTimeout are tied to the service worker's execution context. When the worker is terminated, the timers are destroyed with it. Use chrome.alarms instead — it persists at the browser level and restarts the worker when the alarm fires.
Does the 5-minute service worker limit still exist?
Not as a fixed cap. Modern Chrome resets the idle timer on every event and every extension API response, so a worker doing real work can run well past five minutes. The rule that matters is the idle one: roughly 30 seconds with nothing happening and the worker is stopped.
How do I debug a service worker that keeps stopping?
Open chrome://extensions, enable Developer mode, and click "Inspect views: service worker." Keep that DevTools window closed when reproducing the bug — having it open keeps the worker alive and hides the problem. Log inside each listener with a timestamp; if a log line is missing, that event's handler either wasn't registered in time or relied on lost in-memory state.