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:
- Timers vanish —
setTimeoutandsetIntervalcallbacks are killed mid-flight - In-memory state resets — any variable not persisted to
chrome.storageis gone - Async operations abort — pending
fetch(), file reads, or canvas operations throwAbortError
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:
- Current task progress ("downloading image 7/23")
- Temporary UI state (selected tab, filter options)
- Message queues awaiting delivery
// 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:
- User configuration (per-site settings, CSS rules)
- Cached data (last-used templates, font lists)
- Task queues that must survive browser restart
// 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:
- User preferences that should follow them across devices
- Small config objects (theme, language preference)
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:
- PageShot: Full-page screenshots can take 10+ seconds for tall pages. The task queue tracks capture progress across SW restarts. If the SW dies mid-capture, the alarm fires, checks storage, and resumes from the last completed segment.
- ImageGrabber: Batch downloading 100+ images requires persistent state. Each downloaded image is marked in
storage.session. If the SW restarts, it skips already-downloaded images and continues. - TableGrab: Large tables with merged cells can take significant parsing time. The parser checkpoints its row position after every 50 rows, so a restart doesn't mean starting over.
Production Checklist
- Never use
setTimeoutfor anything critical — alwayschrome.alarms - Persist task state to
storage.sessionafter every meaningful step - Resume interrupted tasks on SW startup (
onStartup+onInstalled) - Use ACK-based messaging for all content script communication
- Implement keep-alive alarms during long operations
- Set
retryCountlimits to prevent infinite retry loops - Log task failures to
storage.localfor debugging