Chrome Extension Long Running Operations — Best Practices
7 min readLong-Running Operations Patterns
Overview
MV3 service workers have a strict 30-second idle timeout. Unlike MV2 background pages that could run indefinitely, your extension must plan for termination at any time. This guide covers patterns for handling tasks that exceed the service worker lifetime.
Challenge: Service Worker Termination
Service Worker Lifecycle:
┌──────────┐
install ──> │ Starting │ ──> activate
└────┬─────┘
│
┌────▼─────┐
events ──> │ Active │ <── wake up
└────┬─────┘
│ idle (~30s)
┌────▼─────┐
│ Idle │
└────┬─────┘
│ timeout
┌────▼──────┐
│ Terminated│ (all state lost)
└───────────┘
Long-running tasks face these challenges:
- Service worker terminates after ~30 seconds of inactivity
- All in-memory state is lost on termination
- No way to extend the timeout directly
Pattern 1: Chunked Processing with Alarms
Break large tasks into small chunks and process one chunk per wake cycle:
// background.ts
interface ProcessingState {
items: string[];
currentIndex: number;
totalProcessed: number;
}
const CHUNK_SIZE = 100;
async function startChunkedProcessing(items: string[]): Promise<void> {
const state: ProcessingState = {
items,
currentIndex: 0,
totalProcessed: 0,
};
await chrome.storage.local.set({ processingState: state });
await chrome.alarms.create("processChunk", { delayInMinutes: 0.1 });
}
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== "processChunk") return;
const { processingState } = await chrome.storage.local.get("processingState");
if (!processingState) return;
const { items, currentIndex } = processingState;
const chunk = items.slice(currentIndex, currentIndex + CHUNK_SIZE);
// Process chunk
for (const item of chunk) {
await processItem(item);
}
// Save progress
processingState.currentIndex += chunk.length;
processingState.totalProcessed += chunk.length;
await chrome.storage.local.set({ processingState });
// Schedule next chunk or complete
if (processingState.currentIndex < items.length) {
await chrome.alarms.create("processChunk", { delayInMinutes: 0.1 });
} else {
await chrome.storage.local.remove("processingState");
notifyCompletion(processingState.totalProcessed);
}
});
Pattern 2: Offscreen Documents for Sustained Work
For tasks requiring longer execution, use an offscreen document:
// background.ts
async function startLongTask(): Promise<void> {
await chrome.offscreen.createDocument({
url: "offscreen.html",
reasons: [chrome.offscreen.Reason.WORKERS],
justification: "Processing large dataset",
});
// Send work to offscreen document
const clients = await self.clients.matchAll();
clients[0]?.postMessage({ type: "START_TASK", data: bigData });
}
// offscreen.ts (runs in offscreen document)
self.onmessage = async (e) => {
if (e.data.type === "START_TASK") {
const worker = new Worker("processor.worker.js");
worker.postMessage(e.data.data);
worker.onmessage = (result) => {
self.postMessage({ type: "PROGRESS", progress: result.data.progress });
};
}
};
Pattern 3: Keep-Alive Heartbeat
For critical background tasks, keep the service worker alive:
// background.ts - NOT recommended for production
// Use alarms instead for reliability
chrome.runtime.onInstalled.addListener(() => {
setInterval(async () => {
await chrome.runtime.getPlatformInfo(); // Keep alive
}, 20000); // Every 20 seconds (< 30s timeout)
});
Prefer alarms — they wake the service worker reliably without polling.
Pattern 4: Resumable State Management
Always save state to chrome.storage for resumability:
interface TaskState {
id: string;
status: "pending" | "running" | "paused" | "completed";
processedCount: number;
lastProcessedKey: string;
checkpoint: Record<string, unknown>;
}
async function saveCheckpoint(state: TaskState): Promise<void> {
await chrome.storage.local.set({ [`task_${state.id}`]: state });
}
async function resumeTask(taskId: string): Promise<void> {
const stored = await chrome.storage.local.get(`task_${taskId}`);
const state = stored[`task_${taskId}`];
if (state && state.status === "running") {
// Resume from checkpoint
await processFromCheckpoint(state);
}
}
Progress Communication
Notify the UI via message passing:
// background.ts
function notifyProgress(count: number, total: number): void {
const progress = Math.round((count / total) * 100);
chrome.runtime.sendMessage({
type: "TASK_PROGRESS",
payload: { count, total, progress }
}).catch(() => {}); // Ignore if popup closed
}
// popup.ts
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "TASK_PROGRESS") {
updateProgressBar(msg.payload.progress);
}
});
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Infinite loops | Blocks event loop, forces termination | Use chunking + alarms |
setTimeout in service worker |
May not fire after termination | Use chrome.alarms |
| Large in-memory state | Lost on termination | Store in chrome.storage |
| Unbounded streaming | Connection drops on termination | Chunk + checkpoint |
Related Patterns
- Service Worker Lifecycle — lifecycle deep dive
- Offscreen Documents — sustained execution contexts
- MV3 Service Workers — official reference
Summary
- Never assume the service worker stays alive — plan for termination
- Use chrome.alarms for periodic work (not
setInterval) - Chunk processing into small units that complete within one wake cycle
- Save state to
chrome.storagefor resumability - Use offscreen documents for CPU-intensive tasks
- Communicate progress back to UI via message passing -e —
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.