Blog
Why Your Chrome Extension Service Worker Keeps Stopping (and How to Survive It)
Why a Chrome extension service worker keeps stopping after 30 seconds, what actually resets the timer, and how to keep state across restarts.
If your Chrome extension service worker keeps stopping and you cannot figure out why, the short answer is: that is by design. A Manifest V3 service worker shuts down after 30 seconds of inactivity, and there is nothing broken about it. The confusion usually comes from expecting the background context to stay alive like an MV2 background page. It does not. It spins up when something needs it, runs, and terminates. Your job is not to keep it alive forever; it is to make sure nothing important lives only in its memory.
This post explains exactly when the worker stops, what resets the timer, and the pattern that keeps your extension correct across restarts.
What "30 seconds of inactivity" actually means
The service worker starts a countdown when it has no work to do. Receiving an event or calling an extension API resets that countdown. So the 30 seconds is not a wall-clock lifespan, it is an idle timer. As long as events keep arriving, the worker keeps running.
The behavior changed meaningfully in Chrome 110. Before that release, service workers were frequently killed at a hard five-minute mark even while they were doing useful work, and not every event reliably reset the idle timer. As of Chrome 110, all events reset the idle timer, and the worker will not hit the idle timeout while there are pending events to process. The five-minute maximum lifetime was removed.
One five-minute limit still exists, and it trips people up: if a single task, one event handler or one API call, takes longer than five minutes to resolve, the worker is terminated regardless of activity. This is a per-operation limit, not a total-lifetime limit. A handler that awaits a slow fetch with no timeout is the classic way to hit it.
Two mechanisms genuinely extend the worker's life, and they are not the same strength. A native messaging port opened with chrome.runtime.connectNative() is a strong keepalive: the worker stays alive as long as the port is connected. A WebSocket is weaker. As of Chrome 116, sending or receiving a message over a WebSocket resets the idle timer, but simply having a socket open does not. If you lean on a WebSocket to stay resident, you have to exchange a message more often than every 30 seconds, or the worker sleeps anyway. Both only matter if your extension actually uses these APIs; they are not a general-purpose way to stay awake.
Why you should not fight the timer
The internet is full of "keepalive" tricks: ping chrome.runtime.getPlatformInfo() every 20 seconds, or open a dummy port, or set a recurring alarm just to poke the worker awake. These work in the sense that they reset the idle timer, but they are the wrong default. You burn battery and memory keeping a worker resident for a user who is not using your extension, and Google has been explicit that artificially extending service worker lifetime is not a supported pattern. If a future Chrome version tightens the rules, keepalive hacks are the first thing to break.
The durable fix is to stop caring whether the worker is alive. Design so that a cold start is indistinguishable from a warm one. That comes down to two rules.
Rule 1: Register event listeners synchronously at the top level
When a new event arrives for a terminated worker, Chrome respawns it and re-runs your top-level script from scratch before dispatching the event. If you register your listeners inside an async function, or after an await, the event that woke the worker can be dispatched before your listener exists, and you miss it.
// GOOD: listener is registered synchronously on the first tick.
chrome.runtime.onInstalled.addListener((details) => {
// ...
});
chrome.alarms.onAlarm.addListener(handleAlarm);
// Only now do async setup.
async function boot() {
const state = await chrome.storage.local.get("config");
// ...
}
boot();
// BAD: by the time this runs, the waking event may already be gone.
async function boot() {
const state = await chrome.storage.local.get("config");
chrome.runtime.onInstalled.addListener(() => {}); // registered too late
}
boot();
This is the single most common reason an onInstalled or onMessage handler "randomly" does not fire. It is not random; the worker was cold and the listener was registered one microtask too late.
Rule 2: Never keep state only in memory
Global variables in a service worker are scratch space. A counter you increment, an in-memory cache, a queue of pending work: all of it evaporates the moment the worker terminates, which can be seconds after your code runs. The rule is simple: anything you would be upset to lose belongs in chrome.storage.local, not in a module-level variable.
// This "works" in testing and silently loses data in production.
let clickCount = 0;
chrome.action.onClicked.addListener(() => {
clickCount++; // gone when the worker sleeps
});
// Read-modify-write against storage instead.
chrome.action.onClicked.addListener(async () => {
const { clickCount = 0 } = await chrome.storage.local.get("clickCount");
await chrome.storage.local.set({ clickCount: clickCount + 1 });
});
Note that reading and writing storage on every event has its own cost, so batch where you can, but the principle holds: the source of truth lives in storage, and memory is only ever a cache you can rebuild.
This restart behavior is also why silent-failure bugs are so hard to catch in MV3. A callback that sets chrome.runtime.lastError and is never checked will vanish along with the worker, and no DevTools were open to see it. We covered that specific trap in catching chrome.runtime.lastError before it silently breaks your extension.
How this affects analytics and event batching
Anything that batches work in the background has to reckon with the worker dying mid-batch. Analytics is the obvious case: if you collect events in a queue and send them every 30 seconds, a worker that terminates at second 29 takes your unsent events with it.
This is a design constraint we built the Moderok SDK around directly. Events live in an in-memory queue for speed, but the queue is mirrored to chrome.storage.local so a terminated worker does not lose them. On the next startup, Moderok.init() loads that persisted state and recovers the pending events back into the queue before doing anything else, so a batch that was interrupted by termination is picked up and sent later instead of dropped. There are bounds on this (the persisted queue is capped so a worker that never gets a chance to flush cannot grow storage without limit), but the default behavior is "survive a restart," not "hope the worker stays alive long enough."
You get the same resilience for the automatic lifecycle events. Because listeners are registered synchronously and the identity is persisted, an __install or __update event fired at a cold start is still recorded correctly even though the worker that receives it was spawned only to handle that one event.
A quick checklist
If your worker keeps stopping and something is misbehaving because of it, walk through this:
- Are all your
chrome.*.on*listeners registered synchronously at the top level, before anyawait? - Is any state that matters kept only in a global variable instead of
chrome.storage? - Does any single event handler risk running longer than five minutes (an un-timed
fetch, a long loop)? - Are you reaching for a keepalive hack when persisting state would solve the actual problem?
The service worker stopping is not the bug. Losing state when it stops is the bug, and that one is entirely in your control.
If you want background analytics that already handles worker termination, batching, and event recovery for you, the Moderok SDK is built for exactly the MV3 service worker model described here.
