Back to Blog

Service Worker Resilience & State Persistence Patterns for MV3 Extensions

July 202612 min read

The 30-Second Problem

In Manifest V3, the Service Worker (SW) is not your friend. It's a temporary worker that Chrome terminates after ~30 seconds of inactivity. This is the single biggest source of production bugs in MV3 extensions, and most tutorials gloss over it entirely.

When your SW dies, three things happen instantly:

If your extension does anything more complex than a context menu click → immediate response, you will hit this. Batch image downloads, long screenshots, table parsing — all of these take longer than 30 seconds.

Storage Layer Strategy

Chrome provides three storage areas. Most developers use them randomly. Here's the production strategy I've refined across 10 extensions:

chrome.storage.session

Ephemeral data that dies when the browser closes. Use for:

// Save task progress — survives SW restart, dies on browser close
await chrome.storage.session.set({
  taskProgress: { current: 7, total: 23, phase: 'download' }
});

chrome.storage.local

Persistent data with no size limit (practical: ~5MB). Use for:

// Per-site configuration — persists forever
await chrome.storage.local.set({
  [`config_${siteKey}`]: { fontSize: 16, fontFamily: 'sans-serif' }
});

chrome.storage.sync

Cross-device sync (100KB limit, ~8KB per item). Use only for:

Rule of thumb: If it's bigger than 1KB or changes frequently, don't use sync. It throttles writes and has strict quota limits.

Persistent Task Queue

For long-running operations (batch downloads, image conversion, table export), you need a task queue that survives SW termination. Here's the pattern I use in PageShot and ImageGrabber:

Queue Structure

const QUEUE_KEY = 'persistentTaskQueue';

class TaskQueue {
  static async enqueue(task) {
    const { queue = [] } = await chrome.storage.session.get(QUEUE_KEY);
    task.id = crypto.randomUUID();
    task.status = 'pending';
    task.createdAt = Date.now();
    queue.push(task);
    await chrome.storage.session.set({ [QUEUE_KEY]: queue });
    return task.id;
  }

  static async dequeue() {
    const { queue = [] } = await chrome.storage.session.get(QUEUE_KEY);
    const task = queue.find(t => t.status === 'pending');
    if (task) {
      task.status = 'running';
      task.startedAt = Date.now();
      await chrome.storage.session.set({ [QUEUE_KEY]: queue });
    }
    return task;
  }

  static async complete(taskId, result) {
    const { queue = [] } = await chrome.storage.session.get(QUEUE_KEY);
    const task = queue.find(t => t.id === taskId);
    if (task) {
      task.status = 'done';
      task.result = result;
      task.completedAt = Date.now();
      await chrome.storage.session.set({ [QUEUE_KEY]: queue });
    }
  }

  static async fail(taskId, error) {
    const { queue = [] } = await chrome.storage.session.get(QUEUE_KEY);
    const task = queue.find(t => t.id === taskId);
    if (task) {
      task.status = task.retryCount < 3 ? 'pending' : 'failed';
      task.retryCount = (task.retryCount || 0) + 1;
      task.lastError = error.message;
      await chrome.storage.session.set({ [QUEUE_KEY]: queue });
    }
  }

  static async resume() {
    // Call on SW startup — pick up interrupted tasks
    const task = await this.dequeue();
    if (task) await this.process(task);
  }
}

Worker Lifecycle Integration

// At the top level of background.js — runs on every SW start
chrome.runtime.onStartup.addListener(() => TaskQueue.resume());
chrome.runtime.onInstalled.addListener(() => TaskQueue.resume());

// Also resume when SW wakes up from an alarm
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'task-resume') {
    await TaskQueue.resume();
  }
});

chrome.alarms: The Only Timer That Survives

setTimeout and setInterval die with the SW. chrome.alarms doesn't. But it has a minimum interval of 30 seconds (1 minute in production). Here's how to work around that:

Alarm Wrapper

class ResilientTimer {
  constructor(name, callback, intervalMs) {
    this.name = name;
    this.callback = callback;
    this.intervalMs = Math.max(intervalMs, 30000); // Chrome minimum
  }

  async start() {
    // Clear any existing alarm with this name
    await chrome.alarms.clear(this.name);
    chrome.alarms.create(this.name, {
      periodInMinutes: this.intervalMs / 60000
    });
  }

  static init() {
    chrome.alarms.onAlarm.addListener(async (alarm) => {
      // Look up the callback from a registry
      const handler = ResilientTimer.registry[alarm.name];
      if (handler) await handler();
    });
  }

  static registry = {};
}

// Usage: heartbeat every 30 seconds to keep SW alive during tasks
const heartbeat = new ResilientTimer('heartbeat', async () => {
  const { activeTask } = await chrome.storage.session.get('activeTask');
  if (activeTask) {
    // Do work — this keeps the SW alive
    await processNextBatch(activeTask);
  }
}, 30000);

Keep-Alive Pattern

When you have an active task, you need to prevent SW termination. The trick: create an alarm that fires every 25 seconds and does minimal work. Chrome won't kill a SW that has active alarm handlers.

async function keepAlive(taskId) {
  await chrome.storage.session.set({ activeTask: taskId });
  await chrome.alarms.clear('keep-alive');
  chrome.alarms.create('keep-alive', { periodInMinutes: 0.4 }); // ~24 seconds
}

async function releaseAlive() {
  await chrome.storage.session.remove('activeTask');
  await chrome.alarms.clear('keep-alive');
}

Message ACK & Retry Architecture

When SW sends a message to a content script and dies before the response arrives, the message is lost. Here's the ACK-based retry system I built for cross-context communication:

Reliable Message Sender

class ReliableMessage {
  static pending = new Map();

  static async send(tabId, message, { timeout = 10000, retries = 3 } = {}) {
    const msgId = crypto.randomUUID();
    const envelope = { ...message, _msgId: msgId, _isRetry: false };

    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        envelope._isRetry = attempt > 0;
        const response = await Promise.race([
          chrome.tabs.sendMessage(tabId, envelope),
          new Promise((_, reject) =>
            setTimeout(() => reject(new Error('timeout')), timeout)
          )
        ]);
        return response; // ACK received
      } catch (err) {
        if (attempt === retries - 1) throw err;
        // SW might have died and restarted — wait then retry
        await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
      }
    }
  }
}

// Content script side — ACK automatically
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg._msgId) {
    // Process the message
    handleMessage(msg).then(result => {
      sendResponse({ _ackId: msg._msgId, result });
    });
    return true; // Keep channel open for async response
  }
});

Real-World Application

These patterns aren't theoretical — they're battle-tested across my production extensions:

Production Checklist

  1. Never use setTimeout for anything critical — always chrome.alarms
  2. Persist task state to storage.session after every meaningful step
  3. Resume interrupted tasks on SW startup (onStartup + onInstalled)
  4. Use ACK-based messaging for all content script communication
  5. Implement keep-alive alarms during long operations
  6. Set retryCount limits to prevent infinite retry loops
  7. Log task failures to storage.local for debugging

References