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.
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.
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.
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.
Cross-browser variation
- Chrome / Edge:
getAllreturns an empty array rather than rejecting when no alarms exist. A packed extension has its periods clamped to a one-minute minimum, soclampedentries 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:
scheduledTimecan 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
createunder two names for one job.
Verification
- 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.
- Reload the extension from
chrome://extensionsand re-runchrome.alarms.getAll()—legacy-cleanupshould be gone and every name inSCHEDULEshould be present exactly once. - 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.
Related
- Minimum alarm period and throttling — why a period you asked for is not the one you get.
- Scheduling daily and weekly syncs — the one-shot pattern the audit re-anchors.
- Alarms that don’t fire after browser restart — the failure mode the startup repair exists to fix.
- Alarms and scheduled background jobs — the parent guide for scheduling in MV3.