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.

Published July 25, 2026 Updated July 25, 2026 9 min read
Table of Contents

The extension polls perfectly for a week, the user quits the browser overnight, and the next morning nothing happens again until they reinstall. Alarms are supposed to survive restarts, and they do — which is exactly why this symptom is confusing. The failure is almost never the alarm store; it is one of three gaps around it. This page is part of Alarms & Scheduling Background Jobs and works through each in the order they are worth checking.

Root cause: three different things all look like “the alarm is gone”

A periodic alarm genuinely persists across restarts. What does not persist is a one-shot alarm that already fired, a schedule that was only ever created inside an onInstalled handler that will not run again, and — the subtlest one — an alarm that fires correctly into a worker with no registered listener, which discards it silently.

Which of the three gaps you haveDecision tree separating a missing alarm, a one-shot alarm that already fired, and an alarm that fires into a worker with no listener.After the restart, does chrome.alarms.getAll() list your alarm?Not listedIt was never recreatedcreated only in onInstalledAdd onStartupand reconcileListed, never firesNo listener at fire timeregistered after an awaitMove it to the top levelsynchronous registrationListed once, then goneIt was a one-shotdelayInMinutes or whenAdd periodInMinutesor recreate on each run
getAll() answers the first question definitively, and the answer decides which of the other two you are looking at.

Step 1. Ask the browser what it is actually holding

Module-scope bookkeeping is erased by every eviction, so the only truthful answer comes from getAll. Run it from the service worker console immediately after a restart, before anything else has had a chance to repair the schedule.

1// Paste into the service worker console on the extensions page.
2const alarms = await chrome.alarms.getAll();
3console.table(alarms.map((a) => ({
4  name: a.name,
5  periodInMinutes: a.periodInMinutes ?? "(one-shot)",
6  firesIn: `${Math.round((a.scheduledTime - Date.now()) / 1000)} s`,
7})));

Execution context: Service worker console, reached from the extensions page. A periodInMinutes of undefined means a one-shot alarm — it will fire once and then no longer exist, which is correct behaviour and the second most common cause of this report. A negative firesIn means the scheduled time has passed and the browser has not delivered it yet, which happens after a machine has been asleep and resolves on its own.

Step 2. Create the schedule from both lifecycle events

chrome.runtime.onInstalled fires on install, on update and on browser upgrade — but not on an ordinary browser start. An extension that only ever creates alarms there will run correctly from the moment it is installed until the first time the user quits the browser.

 1// background.js — both events, one idempotent function
 2const INTENDED = { "poll-feeds": 30, "prune-cache": 720 };
 3
 4async function installSchedule() {
 5  for (const [name, periodInMinutes] of Object.entries(INTENDED)) {
 6    // create() replaces an alarm of the same name, so this is safe to run on every start.
 7    await chrome.alarms.create(name, { delayInMinutes: 1, periodInMinutes });
 8  }
 9}
10
11chrome.runtime.onInstalled.addListener(installSchedule);
12chrome.runtime.onStartup.addListener(installSchedule);

Execution context: Extension service worker, both listeners registered during synchronous evaluation. Recreating an existing alarm resets its next firing to delayInMinutes from now, which is acceptable for a 30-minute poll and would not be for something the user expects at a fixed time — in that case, compare against getAll first and only create what is missing, as the reconcile function in the alarms guide does.

Which lifecycle event fires whenonInstalled covers install, update and browser upgrade; onStartup covers an ordinary browser start; neither fires on a plain worker wake.installweeks lateronInsta…installOrdinary usewakes and evictions onlyBrowser…no event…onStart…browser …onInsta…extensio…Ordinary useschedule intactnothing runs herethe only chance to repair the schedule
The gap in the middle is where this bug lives: a browser start is the one transition onInstalled does not cover.

Step 3. Register the listener before anything can await

An alarm firing into a worker with no onAlarm listener is discarded — there is no queue and no retry. Because the alarm itself is what woke the worker, a listener attached after any await is attached after the dispatch.

 1// background.js — correct: registration is the first thing the module does
 2chrome.alarms.onAlarm.addListener((alarm) => {
 3  (async () => {
 4    const config = await loadConfig();      // awaits belong INSIDE the handler
 5    await handleAlarm(alarm, config);
 6  })();
 7});
 8
 9// Wrong, and it only fails after an eviction:
10// const config = await loadConfig();
11// chrome.alarms.onAlarm.addListener(...)   <- the alarm that woke us already dispatched

Execution context: Extension service worker, top level. The failure is invisible during development because the worker is usually already warm, with a listener registered from a previous wake — it appears only after a genuine cold start, which is what a browser restart guarantees. This is the same registration rule described in service worker fundamentals, and alarms are where it bites hardest because the alarm is often the only thing that ever wakes the worker.

Step 4. Make the job resumable rather than punctual

Even with all three gaps closed, delivery is best-effort: a sleeping machine, a suspended profile or a heavily loaded system will move a firing. Handlers that assume “this runs every 30 minutes” accumulate drift; handlers that ask “how long since the last successful run” do not.

 1// background.js — catch up rather than assume a cadence
 2async function pollFeeds() {
 3  const { lastPollAt = 0 } = await chrome.storage.local.get("lastPollAt");
 4  const elapsedMinutes = (Date.now() - lastPollAt) / 60_000;
 5
 6  // A single firing after a long sleep should do the work once, not once per missed slot.
 7  if (elapsedMinutes < 5) return;              // debounce a burst of coalesced firings
 8  const deep = elapsedMinutes > 180;            // been away a while: fetch a wider window
 9
10  await fetchAndStore({ since: lastPollAt, deep });
11  await chrome.storage.local.set({ lastPollAt: Date.now() });
12}

Execution context: Extension service worker, called from the onAlarm dispatcher. Reading the elapsed time rather than trusting the schedule handles the two real-world cases at once: several coalesced firings arriving together after a wake, and a long gap where a wider fetch window is the right response. Persisting lastPollAt only after a successful run means an interrupted poll is retried rather than skipped.

Step 5. Add a heartbeat you can see

All three gaps share a symptom — silence — so the cheapest permanent fix is to make the schedule observable. A timestamp written on every firing turns “it stopped working” into a question with an answer, both for you and for a user filing a report.

The heartbeat that makes the failure visibleEvery firing records its time; the options page reads it and shows how long ago the last run was, so a stalled schedule is visible without a debugger.AlarmService workerstorage.localOptions pageonAlarm firesset({ lastRunAt: Date.now() })even if the job no-opsread lastRunAt on open"last run 4 minutes ago"stale? off…
A visible last-run time turns three indistinguishable silent failures into one obvious symptom.
1// options.js — surface the heartbeat and offer a one-click repair
2const { lastRunAt = 0 } = await chrome.storage.local.get("lastRunAt");
3const minutesAgo = Math.round((Date.now() - lastRunAt) / 60_000);
4status.textContent = lastRunAt ? `Last run ${minutesAgo} minutes ago` : "Never run";
5
6repairButton.hidden = lastRunAt !== 0 && minutesAgo < 90;
7repairButton.addEventListener("click", async () => {
8  await chrome.runtime.sendMessage({ type: "REPAIR_SCHEDULE" });
9});

Execution context: Options page renderer, with the repair handled in the worker by calling the same idempotent installSchedule function. Writing the timestamp on every firing — including firings where the job decides there is nothing to do — is what distinguishes “the alarm is not firing” from “the alarm fires and the job finds nothing”, which are very different bugs with identical user-visible symptoms.

Cross-browser variation

  • Chrome 120+: alarms persist across restarts and are cleared on uninstall. onStartup fires once per browser session for each installed extension.
  • Firefox 121+: same persistence and the same two events. Because the background context is an event page and terminates less eagerly, a late-registered listener can appear to work here and fail in Chrome.
  • Safari 17+: alarms persist, but delivery is more strongly tied to system power state and can be delayed considerably. The elapsed-time pattern in step 4 is effectively required rather than advisable.
  • All engines: a machine that was asleep at the scheduled time fires late, not never — do not treat a missed slot as a lost alarm until getAll says the alarm is gone.

Verification

  1. Create the alarm, quit the browser completely, reopen it, and run getAll before doing anything else. The alarm must be listed.
  2. Comment out the onStartup registration and repeat. The alarm should still be listed — proving persistence is not the problem — which isolates the gap to creation versus handling.
  3. Move the onAlarm registration below an await and restart. The alarm fires and nothing happens, reproducing the third gap deliberately.
  4. Suspend the machine past a scheduled firing and confirm the handler runs once on wake, with elapsedMinutes reflecting the real gap.

FAQ

Do alarms really survive a browser restart?

Yes. The browser stores them against your extension id, and getAll after a restart proves it. What does not survive is a one-shot alarm that already fired, which is a different thing that produces the same complaint.

Why does onInstalled not run when I restart the browser?

Because nothing was installed or updated. It fires for install, update and browser upgrade only. An ordinary start fires onStartup, which is why the schedule needs both.

Does an alarm wake the worker if the extension has no listener?

The worker starts, the event is dispatched, and with no listener registered it is discarded. Nothing is logged, which is what makes this the hardest of the three gaps to spot.

Should I recreate alarms on every wake to be safe?

No. create resets the next firing time, so doing it on every wake means a 30-minute alarm never gets to 30 minutes if anything else wakes the worker regularly. Repair from onStartup and onInstalled only.

Other Core APIs & Cross-Browser Data Management Resources