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.

A timer and an alarm across the same evictionA setInterval callback lives inside the worker and dies with it, while an alarm is held by the browser and wakes a fresh worker when it fires.setIntervaltimer inside the workerWorker evicted~30 s idleTimer gonesilently, for goodthe alarm survives the same evictionalarms.createheld by the browserWorker evictedsame ~30 sonAlarm wakes a new workercold start, then your handler
The alarm outlives the worker because the browser, not your JavaScript, is holding the schedule.

Prerequisites checklist

  • "alarms" permission in manifest.json. It needs no user-facing justification and is one of the cheapest permissions to defend at store review.
  • A top-level chrome.alarms.onAlarm listener 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.

The three ways to specify when an alarm firesdelayInMinutes, when and periodInMinutes compared on one-shot versus repeating behaviour, restart survival and clamping.OptionRepeats?Survives restartClamped?delayInMinutesNoYes, if pendingFirst run exemptwhen (absolute)NoYes, if pendingFires late if missedperiodInMinutesYesYesMinimum 1 minutedelay + periodYesYesPeriod clamped only
Only periodInMinutes repeats; a one-shot alarm that has already fired is gone and must be recreated.

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.

What the browser does between two firings of a 30-minute alarmThe worker is evicted shortly after the handler finishes and stays gone until the alarm wakes it again for the next run.alarm firesnext firingCold s…parse +…Handle…poll, t…Idle c…~30 sNo worker at all~29 minutesAlarm …cold st…everything in memory is built hereand discarded here
For most of the interval no extension code exists at all — which is the point, and why nothing may be cached in memory between runs.

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. periodInMinutes below 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, scheduledTime and periodInMinutes; 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.
  • getAll is 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.