The symptom is always the same: a network fetch, a bulk IndexedDB migration, or a multi-step encryption pipeline starts working — then stops mid-flight with no error in the console, because the browser terminated the service worker while the work was in progress. The browser enforces a hard idle-eviction policy for MV3 service workers: roughly 30 seconds of inactivity, after which the worker is killed and its V8 heap freed. There is also a less-known 5-minute wall-clock cap on any single worker event-handler invocation in Chrome. This guide covers the few legitimate techniques for extending the active window, the caveats that come with each, and the situations where you should redesign the task instead of fighting the lifecycle. For the underlying lifecycle model, start with Service Worker Fundamentals.
Root cause
The idle timer runs on event-handler activity, not on CPU load. A worker is “idle” the moment all of its event-handler promise chains have resolved and no active ports are open. Even if your JavaScript is running a tight loop inside an IIFE, the browser does not see that as an event — only the event-handler frame counts. The two relevant limits in Chrome:
- ~30 s idle: the standard eviction timeout. Resets when a new event fires or an active port is open.
- 5-minute hard cap: a single event-handler invocation (the promise returned by the handler) may not run longer than 5 minutes. After that, Chrome force-terminates the worker regardless of active ports or
waitUntil calls.
Firefox does not enforce a strict 5-minute cap in current builds, but its idle eviction is non-deterministic. Safari mirrors Chrome’s strict policy. Do not write code that depends on the Firefox leniency.
Technique 1. Alarm-based chunking (recommended)
The most reliable pattern for long-running work is to break it into chunks, each triggered by a chrome.alarms alarm. The worker wakes for each alarm, processes a chunk, writes progress to chrome.storage, and returns. The next alarm wake-up picks up where it left off. This approach is immune to the 5-minute cap because each invocation is a short, complete unit of work.
1// background.js — chunked import job
2
3const CHUNK_SIZE = 50; // records per alarm wake
4
5chrome.runtime.onInstalled.addListener(async () => {
6 // Seed the job queue on install
7 await chrome.storage.local.set({ importOffset: 0, importTotal: 0, importDone: false });
8});
9
10// Kick off the job from a message (e.g. user action in popup)
11chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
12 if (msg.type === 'START_IMPORT') {
13 (async () => {
14 await chrome.storage.local.set({ importOffset: 0, importTotal: msg.total, importDone: false });
15 // Fire the first chunk immediately via a zero-delay alarm
16 await chrome.alarms.create('import-chunk', { delayInMinutes: 0.017 }); // ~1 s
17 sendResponse({ started: true });
18 })();
19 return true;
20 }
21});
22
23chrome.alarms.onAlarm.addListener(async (alarm) => {
24 if (alarm.name !== 'import-chunk') return;
25
26 const { importOffset, importTotal, importDone } =
27 await chrome.storage.local.get(['importOffset', 'importTotal', 'importDone']);
28
29 if (importDone || importOffset >= importTotal) return;
30
31 // Process one chunk
32 const records = await fetchRecords(importOffset, CHUNK_SIZE);
33 await processAndStore(records);
34
35 const nextOffset = importOffset + records.length;
36 const done = nextOffset >= importTotal;
37
38 await chrome.storage.local.set({ importOffset: nextOffset, importDone: done });
39
40 if (!done) {
41 // Schedule next chunk — minimum 1-minute period after first 5 alarms
42 await chrome.alarms.create('import-chunk', { delayInMinutes: 1 });
43 }
44});
Execution context: Extension service worker. The "alarms" permission is required. Chrome enforces a minimum delayInMinutes of approximately 1 minute after the first five alarms fired in a session (the initial five can fire sooner). This means chunked work has an inherent minimum throughput of one chunk per minute — size your chunks accordingly.
Tradeoff: Alarm-based chunking is slow for time-sensitive tasks and introduces at least a 1-minute gap between chunks after the initial burst. For large bulk operations, that is fine. For sub-second latency requirements, this pattern does not apply.
Technique 2. waitUntil to extend a fetch event
If your worker is responding to a fetch event (uncommon in extension service workers, but possible in extension-controlled pages via the scripting API), you can call event.waitUntil(promise) to tell the browser the handler is not done until the promise resolves. This extends the event’s active window without requiring an open port.
1// background.js — extend a functional event with waitUntil
2self.addEventListener('fetch', (event) => {
3 event.waitUntil(
4 (async () => {
5 const response = await caches.match(event.request);
6 if (response) return response;
7 const networkResponse = await fetch(event.request);
8 const cache = await caches.open('mv3-cache-v1');
9 await cache.put(event.request, networkResponse.clone());
10 return networkResponse;
11 })()
12 );
13});
Execution context: Extension service worker, only for fetch events on pages the extension controls. self.addEventListener (the Web API form) is valid here alongside chrome.* listener methods. waitUntil is not available on chrome.runtime.onMessage or other chrome.* events — it exists only on ExtendableEvent subclasses from the standard service worker API.
Caveats: Extension fetch events are niche. Most extensions never intercept fetch events — they use declarativeNetRequest for network modification instead. Do not reach for waitUntil as a general keepalive mechanism; it applies only to the standard SW event types.
Technique 3. Offscreen documents (Chrome/Edge only)
chrome.offscreen creates a hidden tab context that runs independently of the service worker lifecycle. Use it to move heavy computation, DOM parsing, or long-running operations out of the worker entirely. The offscreen document stays alive as long as it exists — it is not subject to the 30-second idle eviction.
1// background.js — delegate heavy work to an offscreen document
2
3async function ensureOffscreen() {
4 if (await chrome.offscreen.hasDocument()) return;
5 await chrome.offscreen.createDocument({
6 url: 'offscreen.html',
7 reasons: [chrome.offscreen.Reason.BLOBS],
8 justification: 'Process large binary payloads outside the service worker',
9 });
10}
11
12chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
13 if (msg.type === 'PROCESS_BLOB') {
14 (async () => {
15 await ensureOffscreen();
16 // Delegate to the offscreen document via message passing
17 const result = await chrome.runtime.sendMessage({
18 target: 'offscreen',
19 type: 'DO_PROCESS',
20 payload: msg.payload,
21 });
22 sendResponse(result);
23 })();
24 return true;
25 }
26});
1// offscreen.js — runs in the offscreen document context
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 if (msg.target !== 'offscreen') return;
4 if (msg.type === 'DO_PROCESS') {
5 (async () => {
6 // Long-running work here — no 30-second limit in this context
7 const result = await heavyDataProcessing(msg.payload);
8 sendResponse({ ok: true, result });
9 })();
10 return true;
11 }
12});
Execution context: The offscreen document runs in a separate renderer process with access to DOM APIs. The service worker orchestrates it via message passing. Chrome 109+, Edge 109+. Firefox and Safari do not support chrome.offscreen — use content scripts or chrome.scripting.executeScript as fallbacks on those browsers.
Caveats: Only one offscreen document can exist per extension at a time. You must provide a reasons array from the chrome.offscreen.Reason enum — picking the wrong reason causes the document creation to be rejected. The document is not subject to idle eviction, but Chrome may still close it if memory pressure is extreme. Always check chrome.offscreen.hasDocument() before creating.
An open chrome.runtime.Port (created with chrome.runtime.connect) prevents the service worker from being evicted while the port is connected. This is how DevTools keeps the worker alive during debugging. A popup or content script can deliberately hold a port open to extend the worker for the duration of a user session.
1// popup.js — hold a port to keep the worker alive during active use
2const port = chrome.runtime.connect({ name: 'popup-keepalive' });
3
4// background.js — acknowledge the port
5chrome.runtime.onConnect.addListener((port) => {
6 if (port.name !== 'popup-keepalive') return;
7 port.onDisconnect.addListener(() => {
8 // Port closed — worker may now be evicted
9 console.debug('[sw] keepalive port disconnected');
10 });
11});
Execution context: chrome.runtime.connect and the resulting Port object are available in any extension context — popup, content script, options page, or service worker. Firefox and Safari support the identical API.
Caveats: This technique works only while the popup is open or the content script is injected. A popup auto-closes when the user clicks away. A content script is tied to the page’s lifetime. Neither is a reliable keepalive for background operations that must outlast user interaction. Relying on this for anything other than “keep the worker alive while the user is actively using the popup” is an architectural mistake. It also does not bypass the 5-minute hard cap.
Why the 5-minute hard cap exists
Chrome’s 5-minute wall-clock limit on event-handler invocations exists to prevent runaway extensions from monopolising system resources. Even with an open port, even with waitUntil, a single handler invocation cannot run longer than 5 minutes. The limit is enforced by the service worker infrastructure in //content/browser/service_worker/, independent of the extension host. It is intentional policy, not a bug.
This means any task that genuinely requires more than 5 minutes of continuous execution must be chunked (see Technique 1) or delegated to a native messaging host outside the browser process.
When NOT to extend the worker
Before applying any of these techniques, ask whether the task needs to run in the service worker at all:
| Task | Better approach |
|---|
| Parsing a large JSON / XML file | Offscreen document or chrome.scripting.executeScript in a page |
| Downloading a large file | fetch with streaming + IndexedDB, return early; resume on next alarm |
| Transcoding or encoding media | Web Worker inside an offscreen document |
| Polling a remote API every few seconds | chrome.alarms with 1-minute minimum; cache aggressively |
| Running a full database migration | Alarm-chunked migration with version flag in storage |
If the real problem is that your data-access pattern requires frequent worker wake-ups, consider restructuring so the popup or options page reads directly from chrome.storage or IndexedDB, without routing through the service worker at all.
Cross-browser variation
- Chrome / Edge: 30-second idle eviction; 5-minute per-invocation hard cap.
chrome.offscreen available from Chrome 109. Alarm minimum period 1 minute after initial burst. - Firefox: No hard 5-minute cap in current builds.
browser.runtime.onSuspend fires before eviction — use it to checkpoint progress. No browser.offscreen — use content scripts. Alarm minimum period not currently enforced in MV3 dev mode but aim for 1-minute chunks for production. - Safari: Strict eviction matching Chrome. No offscreen document support. Alarms may drift under power-saving modes — build a tolerance margin into any time-sensitive chunked job.
Verification
- Open
chrome://extensions → Inspect views → service worker and the Console tab. - Start a long-running task (e.g. trigger
START_IMPORT with a large total). - Watch the console for per-chunk logs. Each alarm wake-up should log a new chunk start.
- After the first 30 seconds, confirm the worker shows as stopped in
chrome://extensions between alarm firings — it should wake, log, and stop again. - Open
chrome://extensions → check the worker is not permanently running. A permanently running worker without an open port means you have an accidental keepalive (common cause: a setInterval registered before Chrome reclaimed the worker). - Simulate a mid-job crash: open the service worker inspector and click Stop. Trigger an alarm manually via
chrome.alarms.getAll() → chrome.alarms.create. Confirm the job resumes from the last saved importOffset, not from zero.
FAQ
Can I use navigator.serviceWorker.active.postMessage to ping the worker and reset the timer?
No. Messages sent via the standard service worker postMessage API do not reset Chrome’s idle eviction timer for extension service workers. Only chrome.runtime events (messages, alarm firings, tab events, etc.) extend the active window.
Does holding a port open bypass the 5-minute hard cap?
No. The 5-minute cap applies to individual event-handler invocations regardless of open ports. An open port prevents the idle-eviction timer from running, but once a handler has been executing for 5 minutes, Chrome terminates the worker.
Is there a way to get a callback just before the worker is evicted?
On Firefox, browser.runtime.onSuspend fires approximately one second before the worker is evicted. On Chrome and Safari, there is no equivalent hook. Design your handlers to write checkpoint state to chrome.storage on every meaningful step rather than relying on a shutdown callback.
Can I spawn a Web Worker inside the service worker to run work on a background thread?
Yes, with caveats. Web Workers inside extension service workers must be registered via absolute chrome-extension:// URLs (new Worker(chrome.runtime.getURL('worker.js'))). The Web Worker itself is not subject to the 30-second idle timer, but it is killed when its parent service worker is evicted. For truly long background threads, pair a Web Worker with an offscreen document (Chrome/Edge) so both the thread and its context survive beyond the service worker’s lifetime.