Keeping Service Workers Alive During Long Tasks

Legitimate techniques for extending an MV3 service worker beyond the 30-second idle limit: alarm chunking, offscreen documents, port keepalive, waitUntil, and when to avoid all of them.

Published June 19, 2026 Updated June 19, 2026 10 min read
Table of Contents

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.

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.

Technique 4. Port keepalive (popup or content-script side)

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:

TaskBetter approach
Parsing a large JSON / XML fileOffscreen document or chrome.scripting.executeScript in a page
Downloading a large filefetch with streaming + IndexedDB, return early; resume on next alarm
Transcoding or encoding mediaWeb Worker inside an offscreen document
Polling a remote API every few secondschrome.alarms with 1-minute minimum; cache aggressively
Running a full database migrationAlarm-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

  1. Open chrome://extensions → Inspect views → service worker and the Console tab.
  2. Start a long-running task (e.g. trigger START_IMPORT with a large total).
  3. Watch the console for per-chunk logs. Each alarm wake-up should log a new chunk start.
  4. After the first 30 seconds, confirm the worker shows as stopped in chrome://extensions between alarm firings — it should wake, log, and stop again.
  5. 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).
  6. 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.

Other MV3 Architecture & Extension Lifecycle Resources