Skip to main content
Technology & EngineeringBrowser Extension218 lines

Background Workers

Service workers and background scripts for event-driven extension logic, lifecycle management, and persistent tasks

Quick Summary11 lines
You are an expert in service workers and background scripts for building browser extensions.

## Key Points

- Register all event listeners synchronously at the top level of the service worker; listeners registered inside async functions or timeouts will be missed after a restart.
- Use `chrome.storage.session` for ephemeral state that should persist across service worker restarts but not browser restarts, and `chrome.storage.local` for durable state.
- Prefer `chrome.alarms` over `setTimeout`/`setInterval` since timers are cancelled when the service worker terminates.
- Storing state in module-level variables and expecting it to persist — the service worker can be terminated at any time and all in-memory state is lost.
- Registering context menus on every service worker startup instead of only inside `onInstalled`, which leads to duplicate entries and errors.
skilldb get browser-extension-skills/Background WorkersFull skill: 218 lines
Paste into your CLAUDE.md or agent config

Background Workers — Browser Extension Development

You are an expert in service workers and background scripts for building browser extensions.

Overview

In Manifest V3, the background page is replaced by a service worker. Service workers are event-driven, have no DOM access, and are terminated when idle. This model reduces memory usage but requires careful state management and event registration.

Core Concepts

Registering the Service Worker

{
  "background": {
    "service_worker": "background.js",
    "type": "module"
  }
}

The "type": "module" field enables ES module import/export syntax in the service worker.

Service Worker Lifecycle

The service worker starts when an event it listens for fires and is terminated after roughly 30 seconds of inactivity (or 5 minutes maximum runtime in some cases).

// Event listeners MUST be registered synchronously at the top level.
// They cannot be registered inside async callbacks or setTimeout.

chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('Extension installed for the first time');
    initializeDefaults();
  } else if (details.reason === 'update') {
    console.log(`Updated from ${details.previousVersion}`);
    migrateData();
  }
});

chrome.runtime.onStartup.addListener(() => {
  console.log('Browser started, service worker activated');
});

Alarms API for Periodic Work

Since service workers are short-lived, use chrome.alarms for recurring tasks:

// Set up an alarm on install
chrome.runtime.onInstalled.addListener(() => {
  chrome.alarms.create('periodicSync', { periodInMinutes: 30 });
});

// Handle alarm events
chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === 'periodicSync') {
    fetchAndCacheData();
  }
});

async function fetchAndCacheData() {
  try {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    await chrome.storage.local.set({ cachedData: data, lastSync: Date.now() });
  } catch (err) {
    console.error('Sync failed:', err);
  }
}

OffscreenDocument for DOM-Dependent Tasks

Service workers have no DOM. When you need DOM APIs (e.g., parsing HTML, using <canvas>), create an offscreen document:

async function ensureOffscreenDocument() {
  const existing = await chrome.offscreen.hasDocument();
  if (!existing) {
    await chrome.offscreen.createDocument({
      url: 'offscreen.html',
      reasons: ['DOM_PARSER'],
      justification: 'Parse HTML content'
    });
  }
}

// offscreen.html includes offscreen.js which listens for messages
// and performs DOM-dependent work

Implementation Patterns

Persisting State Across Restarts

Since global variables are lost when the service worker terminates, persist important state:

// Bad: state is lost on termination
let count = 0;

// Good: persist to storage
async function incrementCounter() {
  const { count = 0 } = await chrome.storage.session.get('count');
  const newCount = count + 1;
  await chrome.storage.session.set({ count: newCount });
  return newCount;
}

chrome.storage.session (MV3) persists for the browser session but is cleared on restart. Use chrome.storage.local for data that should survive restarts.

Context Menus

Register context menu items inside onInstalled, not on every service worker start:

chrome.runtime.onInstalled.addListener(() => {
  chrome.contextMenus.create({
    id: 'lookupSelection',
    title: 'Look up "%s"',
    contexts: ['selection']
  });

  chrome.contextMenus.create({
    id: 'saveImage',
    title: 'Save image via extension',
    contexts: ['image']
  });
});

chrome.contextMenus.onClicked.addListener((info, tab) => {
  switch (info.menuItemId) {
    case 'lookupSelection':
      handleLookup(info.selectionText, tab);
      break;
    case 'saveImage':
      handleSaveImage(info.srcUrl, tab);
      break;
  }
});

Keeping the Service Worker Alive (When Necessary)

For long-running operations, you can extend the lifetime, but use this sparingly:

// Use a periodic alarm to keep alive during a specific operation
async function performLongTask() {
  // Create a keepalive alarm
  chrome.alarms.create('keepalive', { periodInMinutes: 0.4 });

  try {
    await doExpensiveWork();
  } finally {
    chrome.alarms.clear('keepalive');
  }
}

// Alternatively, for tasks under 5 minutes, the service worker
// stays alive as long as events are actively being processed.

Handling Extension Updates

chrome.runtime.onInstalled.addListener(async (details) => {
  if (details.reason === 'update') {
    const { settings } = await chrome.storage.local.get('settings');
    if (settings && !settings.newFeatureFlag) {
      settings.newFeatureFlag = true;
      await chrome.storage.local.set({ settings });
    }
  }
});

Best Practices

  • Register all event listeners synchronously at the top level of the service worker; listeners registered inside async functions or timeouts will be missed after a restart.
  • Use chrome.storage.session for ephemeral state that should persist across service worker restarts but not browser restarts, and chrome.storage.local for durable state.
  • Prefer chrome.alarms over setTimeout/setInterval since timers are cancelled when the service worker terminates.

Core Philosophy

Service workers in Manifest V3 represent a fundamental shift from persistent background pages to an event-driven, ephemeral model. Embrace this constraint rather than fighting it. The service worker should be a thin event dispatcher that responds to browser events, delegates work to appropriate APIs, and persists any state it needs to survive termination. Treat every service worker activation as a potential fresh start.

Design your background logic around the principle of minimal residency. The service worker should do its work and get out of the way, freeing resources for the user's browsing. If you find yourself writing code to keep the service worker alive, that is a signal to reconsider your architecture. Long-running tasks belong behind alarms, offscreen documents, or native messaging — not in tricks that fight the platform's resource management.

Think of the service worker as the nervous system of your extension, not its brain. It routes signals between content scripts, popups, and external APIs, but it should not accumulate complex state or run heavy computation. Keep handlers focused, idempotent where possible, and always prepared for the possibility that the worker was just restarted.

Anti-Patterns

  • Treating global variables as persistent state — storing counters, caches, or configuration in module-level variables and assuming they survive across service worker restarts leads to subtle data loss and inconsistent behavior.

  • Polling with setTimeout/setInterval — using JavaScript timers for periodic work is unreliable because they are cancelled when the service worker terminates; always use chrome.alarms for any recurring task.

  • Registering listeners inside async callbacks — placing event listener registrations inside setTimeout, fetch().then(), or any async path causes them to be missed when the service worker restarts, because Chrome only recognizes listeners registered synchronously at the top level.

  • Keeping the service worker alive artificially — using keepalive alarms or port connections solely to prevent termination wastes system resources and violates the spirit of MV3; restructure the logic to work within the ephemeral model instead.

  • Performing DOM operations directly — attempting to use DOM APIs like document.createElement or DOMParser in the service worker fails silently or throws; offscreen documents exist specifically for this purpose.

Common Pitfalls

  • Storing state in module-level variables and expecting it to persist — the service worker can be terminated at any time and all in-memory state is lost.
  • Registering context menus on every service worker startup instead of only inside onInstalled, which leads to duplicate entries and errors.

Install this skill directly: skilldb add browser-extension-skills

Get CLI access →