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.
Table of Contents
- Root cause: three different things all look like “the alarm is gone”
- Step 1. Ask the browser what it is actually holding
- Step 2. Create the schedule from both lifecycle events
- Step 3. Register the listener before anything can await
- Step 4. Make the job resumable rather than punctual
- Step 5. Add a heartbeat you can see
- Cross-browser variation
- Verification
- FAQ
- Related
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.
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.
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.
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.
onStartupfires 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
getAllsays the alarm is gone.
Verification
- Create the alarm, quit the browser completely, reopen it, and run
getAllbefore doing anything else. The alarm must be listed. - Comment out the
onStartupregistration and repeat. The alarm should still be listed — proving persistence is not the problem — which isolates the gap to creation versus handling. - Move the
onAlarmregistration below anawaitand restart. The alarm fires and nothing happens, reproducing the third gap deliberately. - Suspend the machine past a scheduled firing and confirm the handler runs once on wake, with
elapsedMinutesreflecting 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.
Related
- Minimum alarm period and throttling — why a short period behaves differently in release builds.
- Alarms & Scheduling Background Jobs — the guide this page belongs to.
- Persistent vs non-persistent service workers explained — what else does not survive.
- Debugging a service worker that won’t start — when the worker itself is the problem.