Chaining Alarms for Long-Running Jobs
Split a job that outlives the MV3 service worker into alarm-driven slices — checkpoint progress to storage, resume from the cursor, and finish work no single worker lifetime can.
Table of Contents
You have a job that takes four minutes: re-indexing ten thousand bookmarks, re-fetching a paginated API, or re-encrypting a settings blob. In Manifest V3 the worker running it is torn down after about thirty seconds of inactivity, and an await on a slow network call does not always count as activity. The job dies halfway with no error and no obvious trace. The fix is not to keep the worker alive — it is to stop needing one long-lived worker at all. This guide is part of alarms and scheduled background jobs.
Why one worker cannot finish the job
An MV3 service worker is an event handler with a lifetime, not a process. The browser starts it when an event arrives, keeps it alive while a returned promise is pending, and terminates it once the event queue drains and the idle timer expires. Chrome’s idle timeout is roughly 30 seconds, extended by each new event and by in-flight chrome.* calls, with a hard ceiling around five minutes even under constant activity. A four-minute loop that mostly waits on fetch therefore sits in a window where termination is likely but not guaranteed — which is worse than a hard limit, because it works on your machine and fails on a user’s.
Chaining turns the job inside out. Instead of one function that runs for four minutes, you write one function that runs for two seconds, writes down where it got to, and schedules the next slice. The service worker lifecycle can evict the worker between any two slices without losing a byte, because the only state that matters lives in chrome.storage.
Step-by-step: build a resumable chain
1. Model the job as a cursor
The whole design rests on one rule: at any instant, everything needed to resume must be readable from storage. That means a record with a stable job id, a cursor, a total, and a status.
1// jobs.js — shared by the worker and any UI that reports progress
2const JOB_KEY = "job:reindex";
3
4export async function readJob() {
5 const { [JOB_KEY]: job } = await chrome.storage.local.get(JOB_KEY);
6 return job ?? null;
7}
8
9export async function startJob(total) {
10 const job = { id: crypto.randomUUID(), cursor: 0, total, status: "running", startedAt: Date.now() };
11 await chrome.storage.local.set({ [JOB_KEY]: job });
12 return job;
13}
14
15export async function saveJob(job) {
16 await chrome.storage.local.set({ [JOB_KEY]: job });
17}
Execution context: an ES module imported by the service worker and by extension pages alike; chrome.storage and crypto.randomUUID are available in both. Firefox exposes the same surface under browser.storage, and Safari matches Chrome’s chrome.* aliasing.
2. Do a bounded amount of work per slice
Pick a slice size you can finish comfortably inside a few seconds on a slow machine — not the largest batch that fits. A slice that occasionally takes 25 seconds is a slice that will occasionally be killed.
1const SLICE = 100; // items per alarm tick — tune down, never up, when in doubt
2
3async function runSlice() {
4 const job = await readJob();
5 if (!job || job.status !== "running") return;
6
7 const items = await loadItems(job.cursor, SLICE);
8 for (const item of items) {
9 await indexItem(item);
10 }
11
12 job.cursor += items.length;
13 job.status = job.cursor >= job.total ? "done" : "running";
14 await saveJob(job);
15
16 if (job.status === "running") {
17 chrome.alarms.create("job-tick", { delayInMinutes: 1 });
18 }
19}
Execution context: the service worker. chrome.alarms.create is fire-and-forget and returns before the alarm is registered in older Chrome builds, so treat the following await on storage as the ordering guarantee, not the call itself.
3. Register the listener at the top level
An alarm that fires while the worker is asleep starts the worker and immediately dispatches. If chrome.alarms.onAlarm.addListener is inside an async initialiser that has not run yet, the event is lost. Registration must happen during the first synchronous pass of the worker script.
1// service-worker.js
2import { readJob, saveJob } from "./jobs.js";
3
4chrome.alarms.onAlarm.addListener((alarm) => {
5 if (alarm.name !== "job-tick") return;
6 // Return the promise so the worker stays alive until the slice resolves.
7 return runSlice();
8});
9
10chrome.runtime.onStartup.addListener(resumeIfInterrupted);
11chrome.runtime.onInstalled.addListener(resumeIfInterrupted);
Execution context: top level of the service worker, evaluated on every cold start. Firefox hoists the same rule for event pages; Safari additionally requires the listener to be registered before the first await in the module body.
4. Recover a chain that lost its alarm
Alarms survive browser restarts, but they do not survive every crash, profile migration or extension update. A chain whose last alarm evaporated stays running forever with no tick to advance it. A cheap watchdog fixes this: on startup and on install, if a job claims to be running, schedule one tick.
1async function resumeIfInterrupted() {
2 const job = await readJob();
3 if (job?.status !== "running") return;
4
5 const existing = await chrome.alarms.get("job-tick");
6 if (!existing) {
7 chrome.alarms.create("job-tick", { delayInMinutes: 0.5 });
8 }
9}
Execution context: the service worker, during onStartup/onInstalled. chrome.alarms.get is promise-based in Chrome 88+ and Firefox; in Safari it resolves but may report a stale scheduledTime immediately after a restart, so treat presence — not timing — as the signal. See alarms that don’t fire after a browser restart for the restart-specific failure modes.
5. Make each slice idempotent
Between writing the cursor and finishing the slice there is a window where the worker can die. If that happens, the same items are processed twice on the next tick. Design indexItem so a repeat is harmless — upsert rather than append, and key by the item’s own id rather than by insertion order.
1async function indexItem(item) {
2 const key = `idx:${item.id}`;
3 const existing = await chrome.storage.local.get(key);
4 if (existing[key]?.hash === item.hash) return; // already current
5 await chrome.storage.local.set({ [key]: { hash: item.hash, terms: tokenize(item.title) } });
6}
Execution context: the service worker. Each set counts against the write-rate quota described in handling storage quota exceeded errors — batch writes if a slice touches more than a few dozen keys.
Sizing a slice, and what to do when one goes long
The slice size is the only tuning knob that matters, and the instinct to maximise it is wrong. A slice that takes two seconds on your laptop can take fifteen on a five-year-old machine running a video call, and the browser’s idle timer does not care why. The rule of thumb that survives contact with real installs: pick a size whose worst observed duration is under five seconds, then halve it.
Measuring is cheap, and worth wiring in permanently rather than during tuning only:
1async function runSliceTimed() {
2 const t0 = performance.now();
3 await runSlice();
4 const ms = performance.now() - t0;
5 const { sliceStats = { max: 0, n: 0, total: 0 } } = await chrome.storage.local.get("sliceStats");
6 await chrome.storage.local.set({
7 sliceStats: { max: Math.max(sliceStats.max, ms), n: sliceStats.n + 1, total: sliceStats.total + ms },
8 });
9}
Execution context: the service worker. performance.now is available there and is monotonic, so it is unaffected by the clock changes a long-running chain may live through. Reading the stats back from the options page turns a guess about slice sizing into a number.
When a slice does go long anyway, the chain recovers on its own — the cursor was written before the expensive part, so the next tick starts from the right place and at worst repeats one batch. That is the payoff for making indexItem idempotent in step 5. What the chain cannot recover from is a slice that always exceeds the lifetime: it will loop forever, reprocessing the same batch. Guard against it by recording consecutive failures and stopping.
1if (job.cursor === job.lastCursor) {
2 job.stallCount = (job.stallCount ?? 0) + 1;
3 if (job.stallCount >= 3) { job.status = "stalled"; await saveJob(job); return; }
4}
5job.lastCursor = job.cursor;
Execution context: the service worker, inside the slice runner just before scheduling the next tick. A stalled job should surface in the UI rather than retrying silently — three identical cursors in a row means the slice size is wrong, not that the browser is busy.
Cross-browser variation
- Chrome / Edge: minimum alarm period is one minute for packed extensions;
delayInMinutesvalues below that are clamped silently. Unpacked development builds allow shorter delays, which is why a chain that ticks every 10 seconds locally suddenly ticks every 60 seconds after upload. Size your slices for the released cadence, not the development one. - Firefox: background scripts are event pages rather than true service workers and are considerably more patient, but the same chain works unchanged and is still the right design — Firefox will terminate an idle event page too.
browser.alarmsreturns real promises without a polyfill. - Safari: alarm delivery is best-effort and can be deferred well past
scheduledTimewhen the machine is under power management, so a chain may pause for minutes and then resume. Never derive progress from elapsed wall-clock time; derive it from the cursor. - All three: alarms are cleared when the extension is updated or reloaded. The
onInstalledwatchdog in step 4 is what makes an in-flight job survive an auto-update.
Verification
- Open
chrome://extensions, enable Developer Mode, and click the service worker link for your extension to open its DevTools. - Start the job from your popup or options page, then click Stop on the service worker in
chrome://extensionsto force an eviction mid-chain. - Wait for the next alarm. The worker should restart cold and the console should show the cursor continuing from where it stopped rather than from zero:
[job] tick — cursor 300 / 10000
[job] tick — cursor 400 / 10000
- Confirm the chain is scheduled rather than looping by running this in the service worker console:
1await chrome.alarms.getAll();
2// [{ name: "job-tick", scheduledTime: 1789… , periodInMinutes: undefined }]
Execution context: the service worker’s DevTools console, which shares the worker’s global scope. Exactly one job-tick entry should exist; two means a slice scheduled a duplicate and the job will advance at double rate.
- Reload the extension from
chrome://extensionswhile the job is running and confirm the watchdog reschedules within thirty seconds.
FAQ
Should I use a periodic alarm instead of re-creating one each slice?
A periodic alarm (periodInMinutes) is simpler and is a good fit when the job never ends — a recurring sync, for instance. For a job with a finite end, re-creating a one-shot alarm per slice is safer: there is no periodic alarm left behind if the job finishes, crashes, or is cancelled, and no risk of two ticks overlapping if a slice runs long.
How do I show progress in the popup while the worker is asleep?
Read the job record from storage when the popup opens, and subscribe to chrome.storage.onChanged for live updates. The popup does not need the worker to be running — the cursor is already on disk. This is the same pattern described in storage.onChanged listener patterns.
What if a slice legitimately needs more than thirty seconds?
Then the slice is too big. Split the unit of work further, or move the expensive part somewhere with its own lifetime — an offscreen document for DOM or media work, or the server for anything that does not need the user’s browser at all.
Can I run two chained jobs at once?
Yes, with one alarm name and one storage key per job. Avoid sharing a single job-tick alarm between jobs: the first slice to finish will clear or reschedule it and the other job stalls.
Related
- Alarms that don’t fire after browser restart — why a scheduled chain can go quiet after the browser is relaunched.
- Minimum alarm period and throttling — the clamping rules that decide how fast a chain can tick.
- Keeping service workers alive during long tasks — the keep-alive approach, and when it is the wrong answer.
- Alarms and scheduled background jobs — the parent guide covering scheduling in MV3.