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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

Alarm behaviour by engineMinimum periodic interval, timing precision, behaviour when the system is idle or on battery, persistence across restarts, and API style in Chrome, Firefox and Safari.AspectChrome / EdgeFirefoxSafariMinimum period (packed)1 minuteLower allowedEffectively longerPrecision when awakeGoodGoodCoalescedIdle / batteryMostly on timeMostly on timeDelayedSurvive restartYesYesVersion-dependentAPI stylePromisesPromises (browser.*)Promises (browser.*)
Write for Safari's guarantees and you are correct everywhere.

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.

Reconciling a missed run on SafariSafari delays the five-minute alarm while the system is idle; the user opens the popup, which triggers reconciliation; the background sees the last run was 40 minutes ago, beyond tolerance, and runs the job immediately.SafariBackgroundPopupalarm delayed (system idle)opened → reconcilelastRun 40 min ago > 30 tolerancerun badge-refresh nowlate alarm finally fireslastRun fresh → skip
Alarms are a hint; reconciliation on user-visible moments is the guarantee.

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.

Observed delay of a 5-minute alarm while the machine is idleIllustrative median lateness of a five-minute periodic alarm on an idle laptop on battery in Chrome, Firefox and Safari.Chrome0.2 minutes l…Firefox0.3 minutes l…Safari18 minutes la…
Safari may coalesce idle-time alarms by tens of minutes — reconcile when the user returns.

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; onInstalled must 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

  1. In each browser, create a five-minute alarm and log Date.now() in onAlarm; leave the machine awake and compare intervals.
  2. Leave a laptop idle on battery for an hour and compare logged intervals — expect Safari to lag.
  3. Restart each browser and run await ext.alarms.getAll(): alarms should be present, or recreated by reconcile.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources