Alarms & Scheduling Background Jobs
Schedule work in an MV3 extension with chrome.alarms: minimum periods, surviving restarts, resumable chunked jobs, alarm naming, and the Chrome, Firefox and Safari differences.
setInterval in a service worker is a bug with a delay fuse: it works while the worker is warm and stops permanently the first time the worker is evicted, taking your polling, syncing and cleanup with it. chrome.alarms is the replacement, and it is not a like-for-like one — alarms are named, persistent, throttled to a minimum period, and they wake the worker instead of running inside it. This guide is part of Core APIs & Cross-Browser Data Management and covers scheduling that survives eviction, browser restarts and extension updates.
Prerequisites checklist
-
"alarms"permission inmanifest.json. It needs no user-facing justification and is one of the cheapest permissions to defend at store review. - A top-level
chrome.alarms.onAlarmlistener in the service worker. An alarm that fires with no registered listener is simply lost. -
"storage"permission for job progress. Alarms carry no payload, so anything the handler needs must come from chrome.storage. - An idempotent handler. Alarms can fire after a restart, after an update, and occasionally later than scheduled; the handler must tolerate running twice.
- A period of at least one minute for anything periodic. Shorter periods are clamped in release builds — see the constraints below.
Manifest registration
1{
2 "manifest_version": 3,
3 "name": "Digest Sync",
4 "version": "1.9.0",
5 "permissions": [
6 "alarms", // gates chrome.alarms entirely
7 "storage" // alarms carry no payload; progress lives here
8 ],
9 "background": {
10 "service_worker": "background.js",
11 "type": "module"
12 }
13}
Execution context: Parsed at install time. There is no manifest declaration of the alarms themselves — every alarm is created at runtime and stored by the browser against your extension id, which is why they survive restarts but are cleared on uninstall. Firefox and Safari accept the same permission string and expose the same namespace.
1. Creating an alarm you can reason about later
Alarms are identified by name, and create with an existing name replaces that alarm rather than adding a second one. That makes the API pleasantly idempotent, but it also means a typo silently creates a parallel schedule that nothing ever clears. Use a small constant map of names rather than string literals at call sites.
1// background.js
2export const ALARM = {
3 poll: 'poll-feeds',
4 cleanup: 'prune-cache',
5 chunk: 'import-chunk',
6};
7
8// Called from onInstalled and onStartup — both, because neither alone covers every case.
9async function installSchedule() {
10 // Replaces any existing alarm with the same name, so this is safe to call repeatedly.
11 await chrome.alarms.create(ALARM.poll, {
12 delayInMinutes: 1, // first run shortly after install
13 periodInMinutes: 30, // then every half hour
14 });
15
16 await chrome.alarms.create(ALARM.cleanup, {
17 when: Date.now() + 60 * 60 * 1000, // absolute time, one-shot
18 });
19}
20
21chrome.runtime.onInstalled.addListener(installSchedule);
22chrome.runtime.onStartup.addListener(installSchedule);
Execution context: Extension service worker, top level for the listener registrations and inside the handler for the create calls. delayInMinutes and when are mutually exclusive; passing both is an error. periodInMinutes makes the alarm repeat until cleared, and the repeat survives browser restarts — so calling installSchedule from onStartup is about repairing a missing alarm, not about creating a duplicate. In Firefox the same call signature applies under browser.alarms.
2. Handling alarms with one listener and a switch
A single top-level listener that dispatches on alarm.name is easier to reason about than several listeners, and it makes the “unknown alarm” case explicit — which matters after an update removes a feature but leaves its alarm behind in the browser.
1// background.js — one listener, registered synchronously at the top level
2chrome.alarms.onAlarm.addListener((alarm) => {
3 // The listener itself stays synchronous; the async work runs in an IIFE.
4 (async () => {
5 switch (alarm.name) {
6 case ALARM.poll:
7 await pollFeeds();
8 break;
9 case ALARM.cleanup:
10 await pruneCache();
11 break;
12 case ALARM.chunk:
13 await runNextChunk();
14 break;
15 default:
16 // Left over from a previous version — clear it so it stops waking us.
17 await chrome.alarms.clear(alarm.name);
18 }
19 })();
20});
Execution context: Extension service worker. The registration must happen during the synchronous evaluation of the worker script, because the alarm that woke the worker is dispatched as soon as evaluation finishes — a listener attached after an await misses it, which is the same failure described in service worker fundamentals. Returning from the listener does not cancel the IIFE, but the worker may be evicted once every promise settles, so persist progress before the last await resolves.
3. Turning a long job into a resumable chain
Alarms are the standard answer to the five-minute cap on a single event handler, described in keeping service workers alive during long tasks. The pattern is to make each firing process one bounded chunk, write the new offset, and schedule the next firing — so the job is resumable at every step and an eviction costs one chunk rather than the whole run.
1// background.js — a chunked import that survives eviction and restart
2const CHUNK_SIZE = 50;
3
4export async function startImport(total) {
5 await chrome.storage.local.set({ import: { offset: 0, total, done: false } });
6 // The shortest period the browser will honour; the first delay is allowed to be shorter.
7 await chrome.alarms.create(ALARM.chunk, { delayInMinutes: 0.1, periodInMinutes: 1 });
8}
9
10async function runNextChunk() {
11 const { import: job } = await chrome.storage.local.get('import');
12 if (!job || job.done) {
13 await chrome.alarms.clear(ALARM.chunk);
14 return;
15 }
16
17 const records = await fetchRecords(job.offset, CHUNK_SIZE);
18 await storeRecords(records);
19
20 const offset = job.offset + records.length;
21 const done = records.length === 0 || offset >= job.total;
22
23 // Persist BEFORE the alarm bookkeeping: if we are evicted here, the next firing resumes cleanly.
24 await chrome.storage.local.set({ import: { ...job, offset, done } });
25 if (done) await chrome.alarms.clear(ALARM.chunk);
26}
Execution context: Extension service worker. Each firing is a complete unit of work well inside the per-invocation cap, so the job can run for hours in aggregate without any keepalive trick. The write ordering is the load-bearing detail: progress is persisted before the alarm is cleared, so a crash between the two costs one redundant chunk rather than a lost job. Firefox honours the same one-minute floor for periodInMinutes; Safari may deliver firings less punctually, so never assume a chunk rate faster than the period you asked for.
4. Repairing the schedule after restarts and updates
Alarms survive browser restarts, but they do not survive uninstall, and an extension update can leave you with alarms from the previous version plus none of the new ones. Two events cover the cases, and a reconciliation function makes both idempotent.
1// background.js — converge on the intended schedule, whatever the current state is
2const INTENDED = {
3 [ALARM.poll]: { periodInMinutes: 30 },
4 [ALARM.cleanup]: { periodInMinutes: 720 },
5};
6
7async function reconcileAlarms() {
8 const existing = await chrome.alarms.getAll();
9 const byName = new Map(existing.map((a) => [a.name, a]));
10
11 for (const [name, spec] of Object.entries(INTENDED)) {
12 const current = byName.get(name);
13 if (!current || current.periodInMinutes !== spec.periodInMinutes) {
14 await chrome.alarms.create(name, { delayInMinutes: 1, ...spec });
15 }
16 byName.delete(name);
17 }
18
19 // Anything left is from an older version of this extension.
20 for (const name of byName.keys()) {
21 if (name !== ALARM.chunk) await chrome.alarms.clear(name);
22 }
23}
24
25chrome.runtime.onInstalled.addListener(reconcileAlarms);
26chrome.runtime.onStartup.addListener(reconcileAlarms);
Execution context: Extension service worker. getAll is the only way to see what the browser is actually holding — there is no event for “an alarm was removed”. onInstalled fires for installs, updates and browser upgrades; onStartup fires when a profile that already has your extension starts. Registering both is deliberate: an update that happens while the browser is closed reaches you through onStartup only, and the chunk alarm is deliberately excluded from the pruning so an in-flight import is not cancelled by a version bump.
MV3 constraints to design around
- One-minute minimum period.
periodInMinutesbelow 1 is clamped in release builds; only an unpacked development build honours shorter values. - Alarms carry no payload. The only fields you get are
name,scheduledTimeandperiodInMinutes; everything else must come from storage. - Firing is best-effort, not real-time. A sleeping or heavily loaded machine delivers late, and several alarms can arrive together after a wake.
- A missing listener loses the firing. There is no queue for unhandled alarms.
- Uninstall clears everything. Alarms are not part of the user’s synced profile data.
getAllis the only source of truth. Module-scope bookkeeping is erased by every eviction.
Cross-browser notes
The API shape is identical across engines, so the portable pattern is a namespace alias rather than a branch. The differences are behavioural: Chrome clamps sub-minute periods and delivers reliably while the browser runs; Firefox matches the clamp under MV3 and its event page keeps state between firings, which can hide a missing-persistence bug that Chrome exposes immediately; Safari delivers on a best-effort basis tied to system power state, so treat punctuality as unavailable everywhere and make handlers idempotent.
1const api = (typeof browser !== 'undefined' ? browser : chrome);
2
3export async function schedule(name: string, minutes: number) {
4 // Clamp on our side too, so development and release builds behave the same way.
5 const period = Math.max(1, minutes);
6 await api.alarms.create(name, { delayInMinutes: Math.min(period, 1), periodInMinutes: period });
7}
Execution context: Shared module imported by the background context. Clamping in your own code removes the class of bug where a 20-second period works in an unpacked build and silently becomes 60 seconds for users. For the full namespace picture see cross-browser API compatibility.
Related
- Alarms that don’t fire after browser restart — diagnosing the missing
onStartuppath. - Minimum alarm period and throttling — what the one-minute floor really does.
- Keeping service workers alive during long tasks — why chunking beats extending.
- Chrome Storage API & Sync — where job progress belongs.
- Core APIs & Cross-Browser Data Management — the section this guide belongs to.