Skip to main content

Preventing Service Worker Termination in Chrome Extensions (Manifest V3)

 The migration from Manifest V2 to V3 introduced a fundamental architectural shift: the replacement of persistent Background Pages with ephemeral Service Workers. While this improves browser performance and memory usage, it introduces a critical pain point for developers.

Your background script no longer runs forever. In Chrome, if a Service Worker is inactive for 30 seconds, it is brutally terminated. If a single event takes longer than 5 minutes, it is also terminated.

When the Service Worker dies, two catastrophic things happen:

  1. Global variables are garbage collected. Any state stored in windowlet, or var is lost instantly.
  2. Asynchronous tasks are severed. File uploads, WebSocket connections, and long-polling requests fail silently.

This article details the architectural root cause and provides a robust, production-grade implementation using the Offscreen API to legitimately keep Service Workers alive during critical tasks.

The Root Cause: Event-Driven vs. Persistent

To solve the problem, you must understand the lifecycle.

In Manifest V2, a background page was a hidden HTML document that opened when the browser started and stayed open until it closed. You could rely on in-memory variables (window.userCache) because the execution environment was stable.

In Manifest V3, the Service Worker is event-driven. It follows this cycle:

  1. Wake: Triggered by an event (e.g., chrome.runtime.onMessagechrome.alarms.onAlarm).
  2. Execute: The listener callback runs.
  3. Idle: The browser monitors for activity.
  4. Terminate: After roughly 30 seconds of "idleness," the process is killed.

The Trap: Native JavaScript timers (setTimeoutsetInterval) and fetch promises do not reset the browser's idle timer. Chrome does not consider waiting for a network response as "activity." Consequently, a 60-second file upload will result in the Service Worker dying at the 30-second mark, cancelling the request.

The Solution: State Persistence and Keep-Alives

We need a two-pronged approach to fix this:

  1. Storage Layer: Move state out of memory and into chrome.storage.session.
  2. The Offscreen Keep-Alive: Use a secondary, hidden document to perform "heartbeat" communications that reset the idle timer.

Step 1: Update Your Manifest

To implement the keep-alive strategy, we need the offscreen permission. This API allows us to open a hidden HTML document that does have DOM access and acts as a separate thread.

// manifest.json
{
  "manifest_version": 3,
  "name": "V3 Keep Alive",
  "version": "1.0.0",
  "background": {
    "service_worker": "background.js",
    "type": "module"
  },
  "permissions": [
    "offscreen",
    "storage",
    "runtime"
  ]
}

Step 2: Create the Keep-Alive Offscreen Document

We cannot use setInterval inside the Service Worker because the browser ignores it during the idle check. However, message passing does count as activity.

We will create an offscreen document whose sole purpose is to send a message to the Service Worker every 20 seconds.

offscreen.html

<!DOCTYPE html>
<html>
  <head>
    <script src="offscreen.js"></script>
  </head>
  <body></body>
</html>

offscreen.js

// This script runs inside the offscreen document
// It sends a 'ping' to the service worker every 20 seconds

setInterval(async () => {
  try {
    const response = await chrome.runtime.sendMessage({ 
      target: 'background', 
      type: 'keepAlive' 
    });
    // Optional: Log response for debugging
    // console.log('Keep-alive ping sent', response);
  } catch (err) {
    // If the service worker is dead or unreachable, the browser 
    // might throw an error here. We catch it to prevent console noise.
    console.warn('Keep-alive failed:', err);
  }
}, 20000); // 20 seconds

Step 3: Implement the Service Worker Logic

This is the core logic. We need a controller in background.js that spins up the offscreen document when a long-running task begins and shuts it down when the task ends.

background.js

let creatingOffscreenParams = null; // Semaphore to prevent double creation

/**
 * Ensures the offscreen document is open.
 * Use this before starting any long-running task.
 */
async function setupOffscreenDocument(path) {
  // Check if an offscreen document already exists
  const existingContexts = await chrome.runtime.getContexts({
    contextTypes: ['OFFSCREEN_DOCUMENT'],
  });

  if (existingContexts.length > 0) {
    return;
  }

  // Prevent race conditions where multiple calls try to create the doc
  if (creatingOffscreenParams) {
    await creatingOffscreenParams;
    return;
  }

  // Create the document
  creatingOffscreenParams = chrome.offscreen.createDocument({
    url: path,
    reasons: ['BLOBS'], // 'BLOBS' is a generic reason permitted for keep-alives
    justification: 'Keep service worker alive for long-running task',
  });

  await creatingOffscreenParams;
  creatingOffscreenParams = null;
}

/**
 * Closes the offscreen document.
 * call this immediately after your task finishes to save resources.
 */
async function closeOffscreenDocument() {
  if (creatingOffscreenParams) {
    await creatingOffscreenParams;
  }
  
  // Close all offscreen documents
  await chrome.offscreen.closeDocument();
}

// ---------------------------------------------------------
// Message Handler
// ---------------------------------------------------------

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  
  // 1. Handle the Keep-Alive Ping
  if (message.type === 'keepAlive') {
    // Simply returning true or sending a response resets the idle timer
    sendResponse({ status: 'alive' });
    return;
  }

  // 2. Handle a Long-Running Task (Example)
  if (message.type === 'startHeavyTask') {
    handleHeavyTask().then((result) => sendResponse(result));
    return true; // Keep channel open for async response
  }
});

/**
 * Example of a task that would normally die after 30s
 */
async function handleHeavyTask() {
  try {
    // A. Start the Keep-Alive mechanism
    await setupOffscreenDocument('offscreen.html');

    // B. Perform the heavy lifting
    // Using a fake 45-second delay to simulate a large upload/processing
    console.log('Task started...');
    
    // We persist state to storage in case of accidental crash
    await chrome.storage.session.set({ taskStatus: 'processing' });
    
    await new Promise((resolve) => setTimeout(resolve, 45000));
    
    console.log('Task finished!');
    await chrome.storage.session.set({ taskStatus: 'complete' });
    
    return { success: true };

  } catch (error) {
    console.error('Task failed', error);
    return { success: false, error: error.message };
  } finally {
    // C. TEAR DOWN the Keep-Alive mechanism
    // This is crucial for performance and battery life
    await closeOffscreenDocument();
  }
}

Deep Dive: Why This Works

The "Offscreen Ping" pattern exploits the definition of "activity" in the Chromium engine.

  1. The Isolation: The Service Worker runs in a worker thread. The Offscreen Document runs in a renderer process (similar to a tab, but invisible).
  2. The API Call: When chrome.runtime.sendMessage is called from the Offscreen Document, Chrome wakes up the Service Worker (if sleeping) or resets its termination timer (if active) to handle the onMessage event.
  3. The 20s Interval: We use 20 seconds because the hard limit is 30 seconds. This provides a 10-second buffer for execution latency.
  4. Resource Management: Unlike the deprecated "Highlander" hack (where two scripts pinged each other endlessly), this approach is on-demand. We only create the offscreen document when handleHeavyTask runs, and we explicitly destroy it in the finally block. This adheres to Chrome's performance guidelines.

Handling Global State (The "In-Memory" Trap)

Even with the keep-alive fix, you should never rely on global variables. If the browser crashes or forces an update, your let variables vanish.

Bad Practice (V2 Style):

let processingQueue = []; // Will disappear randomly

Best Practice (V3 Style): Use chrome.storage.session. This is fast, in-memory storage that persists as long as the browser session is open, but survives Service Worker restarts.

// Reading state
const data = await chrome.storage.session.get('processingQueue');
const queue = data.processingQueue || [];

// Writing state
queue.push(newItem);
await chrome.storage.session.set({ processingQueue: queue });

Common Pitfalls and Edge Cases

1. The 5-Minute Wall

The solution above solves the 30-second inactivity limit. However, Chrome has a hard limit of 5 minutes for any single service worker event handler. If your handleHeavyTask takes 10 minutes, the Keep-Alive ping won't save you—Chrome will terminate the worker because the original message port is "too old."

The Fix: For tasks > 5 minutes, you must break the work into chunks or use chrome.alarms to wake the worker up periodically to continue processing from where it left off (using state saved in Storage).

2. Offscreen Document Limits

You can only have one offscreen document open at a time. The setupOffscreenDocument function in the code above handles this by checking getContexts before creating a new one. If you skip this check, your extension will throw an error.

3. Ghost Workers

If your user closes the browser while your task is running, the offscreen document dies. When the browser reopens, the Service Worker might restart, but the offscreen doc won't automatically come back. Always check for pending tasks in chrome.storage.session inside your onStartup listener.

Conclusion

Migrating to Manifest V3 requires a shift from "persistence" to "resilience." By combining the Offscreen API for keep-alives with Storage APIs for state management, you can build extensions that handle complex, long-running tasks reliably without violating modern browser performance standards.

The days of the everlasting background page are over. Embrace the event loop.