Scheduling Daily and Weekly Syncs

Schedule a daily or weekly background sync in MV3 that lands at the right local time, survives sleep and restarts, and does not stampede your server when every install wakes at once.

Published September 18, 2026 Updated September 18, 2026 9 min read
Table of Contents

“Sync once a day” sounds like a one-line alarm. In practice it means: at a time the user considers morning, in their timezone, even if the laptop was shut when that moment passed, without every one of your installs hitting the API in the same second. periodInMinutes: 1440 gets none of that right. This guide is part of alarms and scheduled background jobs.

Why a 1440-minute period drifts

A periodic alarm counts from when it was created, not from a wall-clock time. Create it at 14:20 on a Tuesday and it fires at 14:20 every day — until the machine sleeps through one, the extension updates, or the user changes timezone, at which point the anchor moves. Worse, the anchor is the install moment, so an extension installed during a launch spike gives you thousands of profiles all syncing within the same minute forever.

The durable pattern is to compute the next occurrence as an absolute timestamp, schedule a one-shot alarm when that moment arrives, and recompute after each run. The schedule then follows the calendar rather than an elapsed-time counter.

One sync cycle, from computed target to the nextThe worker computes the next local 07:00, schedules a one-shot alarm for that instant, wakes to run the sync, records the result and recomputes the following target.Alarm registryService workerchrome.storageYour APIonAlarm('daily-sync')cold startget lastSyncedAtGET /changes?since=…jittered200 + payloadset lastSyncedAt, dataalarms.create(when: next 07:00)
Every cycle ends by scheduling exactly one successor — there is never a periodic alarm to drift.

Step-by-step

1. Compute the next local occurrence

Date in a service worker uses the profile’s system timezone, which is what you want: “07:00” should mean the user’s seven o’clock.

 1// Next local occurrence of hour:minute, always strictly in the future.
 2function nextLocalTime(hour, minute = 0, from = new Date()) {
 3  const t = new Date(from);
 4  t.setHours(hour, minute, 0, 0);
 5  if (t <= from) t.setDate(t.getDate() + 1);
 6  return t.getTime();
 7}
 8
 9// Next occurrence of a weekday (0 = Sunday) at hour:minute.
10function nextWeekly(weekday, hour, minute = 0, from = new Date()) {
11  const t = new Date(from);
12  t.setHours(hour, minute, 0, 0);
13  const delta = (weekday - t.getDay() + 7) % 7;
14  t.setDate(t.getDate() + (delta === 0 && t <= from ? 7 : delta));
15  return t.getTime();
16}

Execution context: the service worker, or any extension page — both use the profile’s timezone and the same Date semantics. setHours handles daylight-saving transitions by shifting the wall-clock hour, which is the behaviour a user expects from “07:00”.

2. Add jitter so installs do not stampede

Without jitter, every profile that picked 07:00 hits your API in the same second. A few minutes of per-install randomness, stored once and reused, spreads the load without moving the schedule in a way the user would notice.

1async function installJitterMs() {
2  const { syncJitter } = await chrome.storage.local.get("syncJitter");
3  if (typeof syncJitter === "number") return syncJitter;
4  const jitter = Math.floor(Math.random() * 15 * 60 * 1000); // 0–15 minutes
5  await chrome.storage.local.set({ syncJitter: jitter });
6  return jitter;
7}

Execution context: the service worker. Persisting the jitter rather than re-rolling it keeps the sync time stable for the user; a fresh random value on every cycle would make the sync arrive at a different minute each day.

3. Schedule with when, not delayInMinutes

1async function scheduleDailySync(hour = 7) {
2  const target = nextLocalTime(hour) + (await installJitterMs());
3  await chrome.alarms.create("daily-sync", { when: target });
4}

Execution context: the service worker. when is an absolute epoch-milliseconds timestamp; Chrome, Firefox and Safari all accept it. Using when rather than a relative delay means a machine that sleeps through the target fires as soon as it wakes, instead of silently sliding the whole schedule forward.

4. Run and immediately reschedule

Reschedule before the risky part. If the sync throws, you still want tomorrow’s alarm in place.

 1chrome.alarms.onAlarm.addListener(async (alarm) => {
 2  if (alarm.name !== "daily-sync") return;
 3
 4  await scheduleDailySync();          // tomorrow is booked first
 5
 6  try {
 7    const { lastSyncedAt = 0 } = await chrome.storage.local.get("lastSyncedAt");
 8    const res = await fetch(`https://api.example.com/changes?since=${lastSyncedAt}`);
 9    if (!res.ok) throw new Error(`sync failed: ${res.status}`);
10    const data = await res.json();
11    await chrome.storage.local.set({ lastSyncedAt: Date.now(), data });
12  } catch (err) {
13    console.warn("[sync] deferred to next cycle", err);
14  }
15});

Execution context: the service worker, woken cold by the alarm. The listener is async, and Chrome keeps the worker alive while the returned promise is pending — but only up to the five-minute ceiling, so a sync that could take longer belongs in a chained job.

5. Re-anchor on startup and after an update

Alarms are cleared by an extension update and can be lost in a crash. Both lifecycle hooks should verify the alarm still exists.

1async function ensureScheduled() {
2  const existing = await chrome.alarms.get("daily-sync");
3  if (!existing) await scheduleDailySync();
4}
5
6chrome.runtime.onInstalled.addListener(ensureScheduled);
7chrome.runtime.onStartup.addListener(ensureScheduled);

Execution context: the service worker, top level plus two lifecycle events. On Firefox the same hooks exist under browser.runtime; Safari fires onStartup less predictably, so the onInstalled path carries more weight there.

Requests per minute at a 07:00 sync, with and without jitterA comparison of peak request volume in the first minute after the sync hour for ten thousand installs, scheduled exactly on the hour versus spread over fifteen minutes.No jitter — 07:00 exactly10000 req/minone minute carries everything5 minutes of jitter2000 req/min15 minutes of jitter667 req/mincomfortable60 minutes of jitter167 req/minuser may notice the drift
Fifteen minutes of stored per-install jitter turns a spike into a plateau your API can serve.

Daylight saving, travel and the clock the user believes in

A schedule expressed as “every 1440 minutes” and one expressed as “07:00 local” diverge twice a year, and the divergence is visible to the user in a way most bugs are not: the sync that always arrived before they opened the laptop starts arriving an hour late, for months.

setHours(7, 0, 0, 0) handles this correctly because it operates on wall-clock time. On the day the clock springs forward, the interval between yesterday’s fire and today’s is 23 hours; on the day it falls back, 25. Both are what the user wants — they asked for seven o’clock, not for a 24-hour period.

Two edge cases are worth handling explicitly. The first is a target time that does not exist on a spring-forward day: in zones that jump from 02:00 to 03:00, setHours(2, 30) produces 03:30 in most engines, which is acceptable but worth knowing. The second is a target time that occurs twice on a fall-back day; the earlier instance fires and the schedule moves on, so the sync simply happens once, as intended.

Travel is the case that actually needs code. When the user crosses a timezone, new Date() reports the new zone immediately, but an alarm already scheduled carries an absolute timestamp derived from the old one.

1// Re-anchor whenever the zone changes under us.
2async function checkTimezone() {
3  const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
4  const { lastTz } = await chrome.storage.local.get("lastTz");
5  if (lastTz && lastTz !== tz) await scheduleDailySync();
6  await chrome.storage.local.set({ lastTz: tz });
7}

Execution context: the service worker, called from onStartup and from the sync handler itself. Intl.DateTimeFormat().resolvedOptions().timeZone returns an IANA name such as Europe/Berlin and is available in every extension context on all three engines.

A 07:00 schedule across a spring-forward weekendThree consecutive daily fires around a daylight-saving transition, showing a 23-hour gap on the transition day and 07:00 local maintained throughout.Sat 07:00Mon 07:00Saturday → Sunday24 hClocks forward23 h gapMonday24 hfires 07:00 localfires 07:00 local
The gap changes; the wall-clock time the user sees does not.

Cross-browser variation

  • Chrome / Edge: when timestamps in the past fire immediately on the next worker start, which is exactly the behaviour you want after a laptop wakes from a weekend of sleep.
  • Firefox: identical alarm semantics under browser.alarms, with real promises. Firefox’s event page tolerates longer synchronous work than a Chrome worker, but the five-minute discipline still applies for portability.
  • Safari: alarm delivery is best-effort and may be deferred by tens of minutes under App Nap or low power mode. Always re-derive “did we sync today?” from a stored timestamp rather than assuming the alarm landed on time.
  • All three: the profile’s timezone can change mid-schedule when the user travels. Because each cycle recomputes from new Date(), the next occurrence automatically lands at 07:00 in the new zone.

Verification

  1. Temporarily schedule against a target a couple of minutes out, then inspect the registry from the service worker console:
1await chrome.alarms.get("daily-sync");
2// { name: "daily-sync", scheduledTime: 1789… , periodInMinutes: undefined }

Execution context: the service worker console. Convert scheduledTime with new Date(x) and confirm it reads as the intended local time plus your jitter.

  1. Close DevTools and wait for the alarm. The sync log should appear even though the worker was evicted in the meantime.
  2. Suspend the machine before the target time and resume after it. The alarm should fire within a few seconds of resume rather than waiting a full day.
  3. Change the system timezone, then reload the extension and re-check scheduledTime — it should have moved to 07:00 in the new zone.

FAQ

Should I use periodInMinutes: 1440 for a daily job at all?

Only where the exact time genuinely does not matter — a cache trim, say. As soon as a user would describe the job in terms of a clock time (“every morning”), compute the target and schedule with when.

What happens if the browser is closed at the scheduled time?

The alarm does not fire while the browser is not running. On the next launch, an alarm whose scheduledTime has passed fires shortly after startup. That is why the onStartup re-anchor matters: it also repairs a schedule lost to a crash.

How do I let the user pick the sync hour?

Store the hour in chrome.storage.sync from your options page, and call scheduleDailySync(hour) again from a storage.onChanged listener so the change takes effect without a reload.

Is a weekly sync just a different multiplier?

No — use nextWeekly above. A 10080-minute period drifts for the same reason a 1440-minute one does, and a week of drift is far more visible.

Other Core APIs & Cross-Browser Data Management Resources