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.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

An extension that has shipped a few versions accumulates alarms nobody meant to keep: a periodic alarm from a feature that was removed, two copies of a sync because two code paths both called create, or a one-shot that was scheduled for a moment that has already passed and will never fire again. None of these throw. chrome.alarms.getAll() is the only way to see them, and it takes about twenty lines to turn it into an audit that runs itself. This guide sits under alarms and scheduled background jobs.

What the registry actually holds

The alarm registry is per-extension, persisted in the profile, and keyed by name. Creating an alarm with an existing name silently replaces it — which is usually a relief, but it also means a rename during a refactor leaves the old entry behind forever. Each record carries three fields, and reading them tells you most of what can go wrong.

What each field in an alarm record tells youThe name, scheduledTime and periodInMinutes fields of an alarm record, and the specific fault each one exposes during an audit.FieldReading itHealthySuspiciousnameCompare to your known setIn the setOrphan from an old versionscheduledTimenew Date(scheduledTime)In the near futureFar past, or years awayperiodInMinutesCompare to what you asked…Matches your requestClamped to 1, or unexpect…count of recordsgetAll().lengthOne per live featureGrows across reloads
An audit is mostly three questions: do I recognise this name, has this time already passed, and did I get the period I asked for?

Step-by-step: build the audit

1. Read the registry by hand first

Open the service worker DevTools from chrome://extensions and run:

1(await chrome.alarms.getAll()).map((a) => ({
2  name: a.name,
3  at: new Date(a.scheduledTime).toLocaleString(),
4  every: a.periodInMinutes ?? "one-shot",
5}));

Execution context: the service worker console, which shares the worker’s global scope and its chrome.* bindings. In Firefox the same expression works against browser.alarms; Safari supports getAll but may report a scheduledTime that has not yet been re-anchored after a restart.

2. Declare the schedule you intend to have

An audit needs something to compare against. Keep the intended schedule as data next to the code that creates it, so adding a feature means adding one row rather than remembering to update a checker.

1// schedule.js
2export const SCHEDULE = {
3  "daily-sync":   { kind: "oneshot" },                  // rescheduled after each run
4  "cache-trim":   { kind: "periodic", minutes: 360 },
5  "token-refresh":{ kind: "periodic", minutes: 45 },
6};

Execution context: a module imported by the service worker. Keeping it separate from the creation code means an options page or a test can import the same table without pulling in the worker’s side effects.

3. Find orphans, duplicates and stale one-shots

 1import { SCHEDULE } from "./schedule.js";
 2
 3export async function auditAlarms() {
 4  const live = await chrome.alarms.getAll();
 5  const now = Date.now();
 6  const report = { orphans: [], stale: [], clamped: [], missing: [] };
 7
 8  for (const a of live) {
 9    const want = SCHEDULE[a.name];
10    if (!want) { report.orphans.push(a.name); continue; }
11    if (want.kind === "periodic" && a.periodInMinutes !== want.minutes) {
12      report.clamped.push({ name: a.name, asked: want.minutes, got: a.periodInMinutes });
13    }
14    if (want.kind === "oneshot" && a.scheduledTime < now - 60_000) {
15      report.stale.push(a.name);
16    }
17  }
18
19  const liveNames = new Set(live.map((a) => a.name));
20  for (const name of Object.keys(SCHEDULE)) {
21    if (!liveNames.has(name)) report.missing.push(name);
22  }
23  return report;
24}

Execution context: the service worker. getAll resolves with a snapshot; an alarm that fires while you iterate is not removed from the array you already hold, so treat the report as advisory rather than transactional.

4. Repair automatically on startup

An audit that only logs is a report nobody reads. Have it clear orphans and re-create anything missing.

 1chrome.runtime.onStartup.addListener(repairSchedule);
 2chrome.runtime.onInstalled.addListener(repairSchedule);
 3
 4async function repairSchedule() {
 5  const report = await auditAlarms();
 6
 7  for (const name of report.orphans) await chrome.alarms.clear(name);
 8  for (const name of report.stale)   await chrome.alarms.clear(name);
 9
10  for (const name of [...report.missing, ...report.stale]) {
11    const want = SCHEDULE[name];
12    if (want.kind === "periodic") {
13      await chrome.alarms.create(name, { periodInMinutes: want.minutes });
14    } else {
15      await scheduleOneShot(name);
16    }
17  }
18
19  if (report.clamped.length) {
20    console.warn("[alarms] periods clamped by the browser", report.clamped);
21  }
22}

Execution context: the service worker, on the two lifecycle events that survive an update. chrome.alarms.clear resolves to a boolean telling you whether anything was removed — useful in tests, ignorable here.

5. Surface the report where you can see it

During development, expose the audit through a message so a test or an options page can call it without opening DevTools.

1chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
2  if (msg?.type !== "debug:alarms") return;
3  auditAlarms().then(sendResponse);
4  return true;
5});

Execution context: the service worker. Returning true holds the message channel open until the async audit resolves — the contract described in fixing “message port closed before response” errors. Gate this listener behind a development flag so the debug surface does not ship.

The repair pass on every cold startStartup reads the live registry, compares it with the declared schedule, clears orphaned and stale entries, recreates anything missing and warns about clamped periods.getAll()live registryDiff against SCHEDULEdeclared intentClassifyorphan / stale / missingapply the smallest repair that reconciles the twoclear() orphansremoved featurescreate() missinglost to an updatewarn on clampedperiod the browser refused
Run it on both onStartup and onInstalled — an update clears the registry, a crash can leave it half-populated.

Reading a report: three shapes of trouble

The audit returns four lists, and each one tells a different story about how the extension got into its current state.

Orphans are the residue of a refactor. An alarm named sync was renamed to daily-sync, the new name was created, and nothing ever cleared the old one — so the profile carries a periodic alarm that wakes the worker on a schedule for a handler that no longer exists. The cost is not correctness but battery: every tick starts a worker that does nothing. Orphans accumulate because there is no version of the registry; they are only visible by comparison with your declared schedule.

Missing entries are the opposite, and more urgent. An alarm that should exist and does not means a feature is silently off. The usual causes are an extension update (which clears the registry), a create call that ran before the user granted a permission the handler needs, or an onInstalled listener registered after an await and therefore never called — the failure described in messages sent while the worker is starting.

Clamped entries are informational rather than broken, but they change behaviour in ways that surprise. A token refresh you scheduled for every 45 seconds runs every minute in a packed build. If the token’s lifetime is 60 seconds, that difference is the whole bug.

1// Turn the report into one line a support tool can read back.
2const summary = (r) =>
3  [`orphans:${r.orphans.length}`, `missing:${r.missing.length}`,
4   `stale:${r.stale.length}`, `clamped:${r.clamped.length}`].join(" ");

Execution context: the service worker, or an options page that has messaged the worker for the report. A single string is easy to include in a diagnostics export the user can send you, which is far more useful than asking them to open DevTools.

Reading the audit resultA decision tree mapping each category in the audit report to its usual cause and the repair that fixes it.Which list is non-empty?orphansA rename left residueclear the old nameCosts battery onlyworker wakes for nothingmissingA feature is silently offrecreate and investigateCheck onInstalled orderingregistered before any await?clampedThe browser refused the periodredesign around one minutePacked builds clampunpacked ones may not
Only the missing category is urgent — the others cost battery or surprise, not correctness.

Cross-browser variation

  • Chrome / Edge: getAll returns an empty array rather than rejecting when no alarms exist. A packed extension has its periods clamped to a one-minute minimum, so clamped entries are expected in release builds if you ever asked for less — see minimum alarm period and throttling.
  • Firefox: browser.alarms.getAll() behaves identically and returns native promises. Firefox does not clamp as aggressively, so the same code can report a period of 0.5 that Chrome would report as 1.
  • Safari: scheduledTime can lag reality immediately after a browser restart while the registry is re-anchored; run the audit a few seconds into startup rather than synchronously at the top of the worker.
  • All three: alarms are per-extension, not per-profile-window, so opening a second window does not duplicate them. Duplicates always come from your own code calling create under two names for one job.

Verification

  1. Deliberately create an orphan from the service worker console and confirm the audit finds it:
1await chrome.alarms.create("legacy-cleanup", { periodInMinutes: 30 });
2await chrome.runtime.sendMessage({ type: "debug:alarms" });
3// { orphans: ["legacy-cleanup"], stale: [], clamped: [], missing: [] }

Execution context: the service worker console. The message round-trips through your own listener, so this also verifies the debug channel works.

  1. Reload the extension from chrome://extensions and re-run chrome.alarms.getAll()legacy-cleanup should be gone and every name in SCHEDULE should be present exactly once.
  2. Count the records after three consecutive reloads. A count that grows is a duplicate-creation bug, not an audit problem.

FAQ

Does getAll include alarms created by other extensions?

No. The registry is scoped to your extension id, so you only ever see your own alarms. Nothing you clear can affect another extension.

Is it safe to clear an alarm while its handler is running?

Yes. Clearing removes the future schedule; it does not cancel a dispatch already in flight. If the handler reschedules itself at the end, though, it will win the race and re-add the entry — reschedule at the start of the handler instead.

Should the audit run on every alarm tick?

No. Startup and install are enough. Running it on every tick adds a storage read and a registry scan to a path that should stay cheap, and it can mask a bug by repairing the symptom several times an hour.

Other Core APIs & Cross-Browser Data Management Resources