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.
Table of Contents
The scheduling code works in Chrome: a daily sync fires around the expected time, a five-minute refresh keeps the badge current. In Firefox the refresh fires more often than expected during development and the daily sync drifts; in Safari the five-minute refresh sometimes arrives after forty minutes, and occasionally a one-shot alarm seems to vanish after a restart. The alarms API is one of the most consistent cross-browser extension APIs, but its timing guarantees differ, and code that relies on Chrome’s exact behaviour breaks subtly elsewhere. This guide maps the differences and builds a portable layer. It belongs to alarms and scheduled background jobs.
Same API, different promises
All three engines implement create, get, getAll, clear, clearAll and onAlarm with the same shapes. What differs is everything around them. Chrome clamps periodic alarms to at least one minute (and one-shot delays to at least thirty seconds in recent versions) for packed extensions, and fires reasonably close to the scheduled time when the machine is awake. Firefox does not clamp as aggressively and fires close to schedule, but its MV3 background is an event page with its own suspension rules. Safari treats alarms as a best-effort hint: it coalesces and delays them when the system is idle, on battery, or under memory pressure, and its persistence of alarms across browser restarts has varied between versions. A portable design treats every alarm as “no earlier than” and reconciles on startup.
Step-by-step: a scheduling layer that holds everywhere
1. Use one namespace
1// platform.js
2export const ext = globalThis.browser ?? globalThis.chrome;
Execution context: a shared module. Firefox and Safari expose browser.alarms with native promises; Chrome exposes chrome.alarms, also promise-based in MV3. Firefox and Safari also provide a chrome alias, so either name works — picking one through a constant keeps the code uniform. No polyfill is needed for alarms.
2. Declare schedules as data with tolerances
1// schedule.js
2export const SCHEDULE = {
3 "badge-refresh": { every: 5, tolerance: 30 }, // minutes
4 "daily-sync": { every: 24 * 60, tolerance: 6 * 60 },
5 "token-refresh": { every: 45, tolerance: 10 },
6};
Execution context: a shared module. The tolerance states how late a run may be before it is considered missed — a badge refresh thirty minutes late is fine, a token refresh fifty-five minutes late may not be. Writing it down forces the conversation about what “every five minutes” actually needs to mean on a browser that will not promise it.
3. Record when each job last ran
1ext.alarms.onAlarm.addListener(async ({ name }) => {
2 const job = JOBS[name];
3 if (!job) return;
4 await job();
5 const { lastRun = {} } = await ext.storage.local.get("lastRun");
6 lastRun[name] = Date.now();
7 await ext.storage.local.set({ lastRun });
8});
Execution context: the background context in each engine (service worker in Chrome and Safari, event page in Firefox). Recording completion time — not just that the alarm fired — gives you the ground truth the next step reconciles against. Keep the listener at the top level in every engine; Firefox’s event page also needs it registered synchronously to be woken by the alarm.
4. Reconcile on startup and on user-visible moments
1export async function reconcile(reason) {
2 const { lastRun = {} } = await ext.storage.local.get("lastRun");
3 const now = Date.now();
4 for (const [name, { every, tolerance }] of Object.entries(SCHEDULE)) {
5 const overdue = now - (lastRun[name] ?? 0) > (every + tolerance) * 60_000;
6 if (overdue) await JOBS[name]();
7 if (!(await ext.alarms.get(name))) await ext.alarms.create(name, { periodInMinutes: every });
8 }
9}
10
11ext.runtime.onStartup.addListener(() => reconcile("startup"));
12ext.runtime.onInstalled.addListener(() => reconcile("installed"));
13ext.runtime.onMessage.addListener((m) => { if (m?.type === "reconcile") reconcile("ui"); });
Execution context: the background context. Startup reconciliation recreates any alarm that did not survive a restart (Safari) or an update (all engines), and catches up overdue jobs. Calling it when a popup or side panel opens means that on Safari, where background alarms may lag badly, the user still sees fresh data the moment they look. Jobs must be idempotent, because an overdue run and a late alarm can both happen.
5. Avoid relying on sub-minute periods
1// Development-only fast schedule; packed Chrome clamps it anyway
2const DEV = !("update_url" in ext.runtime.getManifest());
3const every = DEV ? 0.5 : SCHEDULE[name].every;
Execution context: the background context. Unpacked Chrome extensions and Firefox may honour very short periods, which makes bugs that depend on frequent firing appear in development and vanish in production. The absence of update_url is a common heuristic for an unpacked build in Chrome. Better still, test with production intervals and drive jobs manually from tests.
6. Handle Firefox’s event page lifecycle
1// Firefox MV3 manifest fragment
2// "background": { "scripts": ["background.js"], "type": "module" }
3// The event page is suspended when idle and woken by alarms like a service worker.
4browser.runtime.onSuspend?.addListener(() => {
5 // flush any in-memory buffers; do not schedule new work here
6});
Execution context: the Firefox background event page. Firefox fires runtime.onSuspend shortly before suspending an event page, which Chrome’s service workers do not. It is a convenient place to flush buffered writes, but treat it as best-effort — a crash or a forced shutdown skips it. Code written for Chrome’s model, which assumes no warning, is already correct in Firefox.
Common mistakes
- Assuming alarms fire on time. On Safari especially, they may be very late. Reconcile on startup and when UI opens.
- Not re-creating alarms after updates. Updates clear alarms in every engine;
onInstalledmust recreate them. - Using alarm timing as a clock. “The alarm fired, so five minutes passed” is false. Compare with stored timestamps.
- Short development periods. Behaviour that depends on fast alarms disappears in packed builds.
- Different code paths per browser. One schedule table and one reconcile function work everywhere.
Cross-browser variation
- Chrome / Edge: one-minute minimum for periodic alarms in packed builds; 30-second minimum for one-shot delays in recent versions; reliable when awake.
- Firefox: fewer clamps; event-page background with
onSuspend; alarms persist across restarts. - Safari: best-effort timing with significant delays when idle or on battery; verify alarm persistence after restarts on the Safari versions you support and always reconcile.
Verification
- In each browser, create a five-minute alarm and log
Date.now()inonAlarm; leave the machine awake and compare intervals. - Leave a laptop idle on battery for an hour and compare logged intervals — expect Safari to lag.
- Restart each browser and run
await ext.alarms.getAll(): alarms should be present, or recreated byreconcile. - Open the popup after a long idle period and confirm overdue jobs run immediately.
FAQ
Does Safari support chrome.alarms at all?
Yes, as browser.alarms (with a chrome alias). The API exists; the timing is looser.
Can I use setTimeout in Firefox’s event page instead?
Short timeouts work while the page is awake, but the page is suspended when idle and timers do not survive suspension. Use alarms for anything longer than a few seconds.
Should I schedule more frequently on Safari to compensate?
No — Safari will still coalesce them. Reconcile at user-visible moments instead.
How should I test alarm timing across engines?
Log every firing with its scheduledTime and the actual Date.now() to a small ring buffer in storage, then export it from a debug page after a day of normal use. The difference between the two columns is the real lateness on that machine and engine, which is far more informative than any documentation and catches regressions when a browser update changes scheduling behaviour.
Do alarms fire while the computer sleeps?
No engine fires alarms during system sleep. On wake, Chrome and Firefox fire overdue alarms once (not once per missed period); Safari may delay them further. Reconciliation on startup and on wake-related UI opens covers all three.
Related
- Alarms that don’t fire after browser restart — the restart problem in depth.
- Minimum alarm period and throttling — Chrome’s clamps.
- Background script support across browsers — the background models alarms wake.
- Alarms and scheduled background jobs — the parent topic.