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.

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. 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.

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’.

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'.

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 That Don't Fire After Browser Restart

Diagnose an MV3 alarm that stops firing after the browser is closed: the onStartup gap, one-shot alarms that already fired, listeners registered too late, and how to reconcile the schedule.

Minimum Alarm Period and Throttling

Why a sub-minute chrome.alarms period works unpacked and is clamped for users, how the first delay differs from the repeat period, and what to use when you genuinely need faster ticks.