Originally published on ExtensionLaunch.
Your Chrome extension service worker works perfectly while you test it, then silently drops state, misses events or "forgets" a timer once real users have it. That is not a Chrome bug: in Manifest V3 the background script is built to stop and restart, and your code has to expect it. Here are the shutdown rules and four habits that keep an extension working through them.
"Service worker (inactive)" is not an error
On chrome://extensions you will often see service worker (inactive) next to your extension. It only means the worker is stopped right now.
Chrome starts the worker when something it listens for happens (a toolbar click, a message, an alarm) and stops it again when things go quiet. It is only a bug if an event arrives and your code never responds.
The lifecycle in short:
-
Install:
installfires, thenchrome.runtime.onInstalled(also on extension and Chrome updates), thenactivate. -
Startup:
chrome.runtime.onStartupfires when a user profile starts. - Idle: with nothing to do, Chrome shuts the worker down.
- Wake: a registered event fires and Chrome boots the worker again, from scratch.
When Chrome kills your worker
Chrome terminates the service worker when any of these happen:
| Trigger | Limit |
|---|---|
| No events or extension API calls | ~30 seconds |
| A single event or API call runs too long | 5 minutes |
A fetch() response takes too long to arrive |
30 seconds |
Since Chrome 110, every extension event or API call resets the idle timer, so the old hard 5-minute lifetime is gone. A few other things also keep it alive: an active WebSocket with traffic (Chrome 116+), a message over a long-lived port (114+; just opening the port does not count), an active chrome.debugger session (118+) and a native messaging connection (105+).
When the worker stops, all globals and pending timers are gone. That one fact explains most "works on my machine" extension bugs.
4 rules for a worker that survives termination
1. Register listeners at the top level, synchronously.
A listener added inside a promise or callback may not exist yet when the event that woke the worker is dispatched.
// background.js
chrome.runtime.onMessage.addListener(handleMessage); // good: top level
chrome.alarms.onAlarm.addListener(handleAlarm);
// bad: may miss the event that woke the worker
// chrome.storage.local.get('cfg').then(() => {
// chrome.runtime.onMessage.addListener(handleMessage);
// });
2. Keep state in chrome.storage, not in globals.
Use chrome.storage.local for data that must persist and chrome.storage.session for data that should clear when the browser closes.
async function bumpCounter() {
const { count = 0 } = await chrome.storage.session.get('count');
await chrome.storage.session.set({ count: count + 1 });
}
3. Replace setTimeout / setInterval with chrome.alarms.
A timer dies with the worker; an alarm wakes it up. For packed extensions, alarms fire at most once every 30 seconds (supported since Chrome 120), so shorter delays are not honored. Chrome 150 added persistAcrossSessions for alarms.
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('sync', { periodInMinutes: 5 });
});
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') syncData();
});
4. Move DOM work to an offscreen document.
Service workers have no DOM. Parsing HTML, playing audio or touching the clipboard belongs in an offscreen document, and messages from it also reset the worker's timers.
Keep-alive: only for a task in progress
If one job genuinely needs more time, you can extend the worker's life until that job finishes by calling a trivial extension API on an interval. Treat it as a last resort:
async function waitUntil(promise) {
const keepAlive = setInterval(chrome.runtime.getPlatformInfo, 25 * 1000);
try {
return await promise;
} finally {
clearInterval(keepAlive);
}
}
Keeping a worker alive indefinitely is a different story: the original guide's reading of Google's policy is that it's only allowed for enterprise and education extensions on managed devices.
Debug it like a user would hit it
- Close DevTools while testing. Inspecting the worker keeps it alive, which hides exactly the bugs you are looking for.
- Use the DevTools Application panel to stop and start the worker manually and check that your extension recovers.
-
"Status code: 15" at registration usually means your script threw during its first run (a typo like
oninstalledinstead ofonInstalledis enough). Fix the error logged right after it.
Key takeaways
- "Inactive" is the normal resting state of a Manifest V3 service worker.
- Expect shutdown after ~30s idle, a 5-minute single call, or a 30s slow
fetch(). - Top-level listeners,
chrome.storagefor state,chrome.alarmsfor timing, offscreen documents for DOM work. - Use keep-alive only to finish a specific long task.
- Test with DevTools closed.
The original article goes deeper, including a version-by-version table of lifecycle changes and an FAQ: read the full Chrome extension service worker lifecycle and keep-alive guide. If you are getting ready to ship, ExtensionLaunch has guides and a launch platform for Chrome extension developers.
What is the strangest bug a sleeping service worker has caused in your extension, and how did you track it down?
Top comments (0)