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.
5. Choosing the right kind of schedule
Most scheduling bugs in extensions are not bugs in the alarms API but mismatches between the kind of schedule the feature needs and the kind the code expresses. There are four kinds, and naming them explicitly makes the right implementation obvious.
A fixed interval — “refresh the cache every six hours” — cares about elapsed time and nothing else. A periodic alarm with periodInMinutes is exactly right, recreated in onInstalled and onStartup so an update or a lost registry never leaves it missing.
A wall-clock time — “sync at seven every morning” — cares about the user’s calendar, not elapsed time. A periodic alarm drifts from it as soon as the machine sleeps through one run or the clocks change. Compute the next occurrence as an absolute timestamp, schedule a one-shot alarm with when, and recompute after each run, as described in scheduling daily and weekly syncs.
A deferred reaction — “reconcile a minute after the last change” — cares about coalescing a burst of events into one piece of work. A one-shot alarm created by the first event of the burst, guarded by a flag in storage.session, runs the work once after the burst settles.
A long job — “re-index ten thousand items” — cares about finishing, across as many worker lifetimes as it takes. A chain of one-shot alarms, each processing a bounded slice and advancing a cursor in storage, is the only shape that survives eviction reliably.
Picking the kind first and the API second avoids the most common mistake in this area, which is using a periodic alarm for everything because it is the first thing the documentation shows.
6. Observing schedules in production
Alarms fail silently. A missing alarm does not throw; it simply never fires, and the feature it drives stops without any error for error reporting to catch. The defence is to make the schedule itself observable.
Record the last successful run of every scheduled job in storage, alongside the alarm’s intended period. A small check — run from onStartup, from the popup, or as part of a diagnostics export — can then compare “when did this last run?” with “how often should it run?” and flag any job that is overdue by more than, say, twice its period. That single comparison catches a missing alarm, a handler that throws before recording success, and a job that has been clamped to a slower cadence than intended.
1async function overdueJobs(schedule) {
2 const { lastRun = {} } = await chrome.storage.local.get("lastRun");
3 const now = Date.now();
4 return Object.entries(schedule)
5 .filter(([name, minutes]) => now - (lastRun[name] ?? 0) > 2 * minutes * 60_000)
6 .map(([name]) => name);
7}
Execution context: the service worker or any extension page. Pairing this with the registry audit in auditing scheduled alarms with getAll distinguishes the two failure modes — an alarm that is missing, and an alarm that fires but whose job does not complete — which need different fixes.
7. Respecting the user’s machine
Every alarm wakes the service worker, and every wake costs a little CPU, memory and battery. For a single extension on a desktop that cost is negligible; multiplied across the dozen extensions a typical user runs, on a laptop running from battery, it is not. Scheduling well includes scheduling rarely.
Two questions keep the cost down. First, does the job need to run on a timer at all, or could it run when the user next opens the popup or visits a relevant page? Work triggered by the user’s own actions costs nothing while they are away. Second, if it does need a timer, what is the longest interval the feature can tolerate? A cache that is refreshed every six hours instead of every ten minutes is almost always indistinguishable to the user and wakes the worker thirty-six times less often. Where freshness genuinely matters, fetch on demand and refresh on a slow schedule, rather than polling fast in the background just in case.
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.
Further guides in this topic
The guides below go deeper into specific alarms and scheduling background jobs problems that the sections above only touch on — each one starts from a concrete symptom and ends with a way to verify the fix.
- Alarms vs setTimeout in Service Workers — Why setTimeout and setInterval silently stop working in an MV3 service worker, which delays still work, and how to convert each timer pattern to chrome.alarms.
- Auditing Scheduled Alarms with getAll — Use chrome.alarms.getAll to find duplicate, orphaned and clamped alarms in a running MV3 extension, and build a small self-audit that repairs the schedule on every startup.
- 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.
- Scheduling Daily and Weekly Syncs — Schedule a daily or weekly background sync in MV3 that lands at the right local time, survives sleep and restarts, and does not stampede your server when every install wakes at once.
- Alarms in Firefox and Safari — How chrome.alarms behaves in Firefox and Safari: browser.alarms promises, minimum periods and clamping, delayed firing on idle and battery, persistence across restarts, and a portable scheduling layer.
- Deferring Heavy Jobs Until the Browser Is Idle — Run expensive MV3 background work only when the user is away: chrome.idle states and detection intervals, combining idle events with alarms, deadlines so jobs still run, and Firefox and Safari behaviour.
- Persisting Job Progress Across Worker Restarts — Make long MV3 background jobs resumable: checkpoint cursors in chrome.storage, process in bounded batches, use leases to prevent overlap, and resume from the last checkpoint after the service worker is terminated.
- Retrying Failed Background Jobs with Backoff — Retry failed MV3 background jobs safely: classify transient and permanent failures, exponential backoff with jitter on chrome.alarms, a durable attempt record, caps, dead-letter handling and user-visible status.
- Showing the Next Scheduled Run in the UI — Display ’last synced’ and ’next sync’ times in an MV3 extension popup or options page: read alarms with chrome.alarms.get, store last-run results, format relative times with Intl, and offer ‘run now’.
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.