Showing the Next Scheduled Run in the UI

Display 'last synced' and 'next sync' times in an MV3 extension popup or options page: read alarms with chrome.alarms.get, store last-run results, format relative times with Intl, and offer 'run now'.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

Users who rely on a background sync want to know two things: when did it last succeed, and when will it run next? Without that, every stale item looks like a bug and every support request starts with “is it syncing at all?”. The information exists — alarms know their scheduled time, and your job can record its own results — but it lives in the service worker, while the user is looking at the popup. This guide connects the two with a small status model, accurate relative times, and a “Sync now” button that does not fight the schedule. It belongs to alarms and scheduled background jobs.

Where the status information comes from

chrome.alarms.get(name) returns the alarm’s scheduledTime — milliseconds since the epoch — and its periodInMinutes. That is the next run. The last run is not recorded by the browser at all; your job has to write it, along with whether it succeeded and what it did. Extension pages such as the popup can call chrome.alarms.get directly, since they share the extension’s API surface with the worker, and can read the job’s record from chrome.storage. Neither needs a message to the worker. The subtlety is that scheduledTime is a promise, not a fact: Chrome clamps it, Safari delays it, and a job waiting for idle (as in deferring heavy jobs until the browser is idle) may run well after its alarm fires.

Building the sync status lineThe popup reads the alarm's scheduled time, the job's last-run record and any in-progress marker from storage, combines them into a status model, and renders relative times that refresh every 30 seconds.alarms.get("sync")scheduledTimestorage: syncStatuslastOk, lastError, runningStatus modelstate + timesrender and keep freshIntl.RelativeTimeFormat"in 12 minutes"<time datetime>accessible absolutestorage.onChangedlive updates
Two sources — the alarm for the future, your record for the past.

Step-by-step: an honest status line

1. Record outcomes in the job

 1// sw.js
 2async function runSync(trigger) {
 3  await setStatus({ running: true, startedAt: Date.now(), trigger });
 4  try {
 5    const { changed } = await syncAll();
 6    await setStatus({ running: false, lastOk: Date.now(), lastChanged: changed, lastError: null });
 7  } catch (err) {
 8    await setStatus({ running: false, lastErrorAt: Date.now(), lastError: String(err?.message ?? err) });
 9    throw err;
10  }
11}
12
13async function setStatus(patch) {
14  const { syncStatus = {} } = await chrome.storage.local.get("syncStatus");
15  await chrome.storage.local.set({ syncStatus: { ...syncStatus, ...patch } });
16}

Execution context: the service worker. The record keeps the last successful time separately from the last error, because “last synced 2 hours ago” and “last attempt failed 5 minutes ago” are both true and both useful. The running flag lets the UI show a spinner; if the worker dies mid-job, the flag is left set, which step 3 handles with startedAt.

2. Read the next run directly from the alarm

1// popup.js
2async function readStatus() {
3  const [alarm, { syncStatus = {} }] = await Promise.all([
4    chrome.alarms.get("sync"),
5    chrome.storage.local.get("syncStatus"),
6  ]);
7  return { next: alarm?.scheduledTime ?? null, period: alarm?.periodInMinutes ?? null, ...syncStatus };
8}

Execution context: the popup, which can call chrome.alarms itself. If alarm is undefined, the schedule is missing — after an update that failed to recreate it, for example — and that is worth showing, not hiding. In Firefox and Safari the same calls work through browser.alarms.

Status states and what to showFive sync states — running, ok, failed recently, stale, and no schedule — with the condition that identifies each and the text the popup shows.StateConditionShowRunningrunning, started < 5 min agoSyncing…OKlastOk after lastErrorAtSynced 12 min ago · next in 48 minFailedlastErrorAt after lastOkLast attempt failed · retrying in 4 minStalelastOk older than 2 periodsNot synced since yesterdayNo schedulealarm missingSync is not scheduled — repair
Every state answers 'is it working?' in one line.

3. Derive a single state

1function deriveState(s, now = Date.now()) {
2  if (s.running && now - (s.startedAt ?? 0) < 5 * 60_000) return "running";
3  if (!s.next) return "unscheduled";
4  if (s.lastErrorAt && (!s.lastOk || s.lastErrorAt > s.lastOk)) return "failed";
5  if (s.period && s.lastOk && now - s.lastOk > 2 * s.period * 60_000) return "stale";
6  return s.lastOk ? "ok" : "never";
7}

Execution context: the popup, or a shared module. A running flag older than the event limit means the worker died mid-job; treating it as not running avoids a spinner that never stops. “Stale” catches the case where alarms are firing but jobs are not completing — the most confusing failure for users and the one most worth surfacing.

4. Format times for humans and machines

 1const rtf = new Intl.RelativeTimeFormat(chrome.i18n.getUILanguage(), { numeric: "auto" });
 2
 3function relative(ts, now = Date.now()) {
 4  const diff = ts - now, abs = Math.abs(diff);
 5  if (abs < 60_000) return rtf.format(Math.round(diff / 1000), "second");
 6  if (abs < 3600_000) return rtf.format(Math.round(diff / 60_000), "minute");
 7  if (abs < 86_400_000) return rtf.format(Math.round(diff / 3600_000), "hour");
 8  return rtf.format(Math.round(diff / 86_400_000), "day");
 9}
10
11function timeEl(ts) {
12  const t = document.createElement("time");
13  t.dateTime = new Date(ts).toISOString();
14  t.title = new Date(ts).toLocaleString(chrome.i18n.getUILanguage());
15  t.textContent = relative(ts);
16  return t;
17}

Execution context: the popup. Intl.RelativeTimeFormat produces “in 12 minutes” or “vor 2 Stunden” in the browser’s UI language with no translation strings. The <time> element’s datetime attribute and the title with the absolute time help screen readers and users who hover. If the next run is in the past — an overdue alarm — show “due now” rather than “1 minute ago”.

Live updates while the popup is openThe popup renders the status; the alarm fires and the worker marks running in storage; the popup re-renders a spinner; the job completes and writes lastOk; the popup re-renders with new times.Popupstorage.localService workerread syncStatus + alarmrunning: trueonChanged → "Syncing…"lastOk: nowonChanged → "Synced just now"
storage.onChanged keeps the popup current without any messaging protocol.

5. Keep it fresh while visible

 1let timer;
 2async function render() {
 3  const s = await readStatus();
 4  statusEl.replaceChildren(...view(deriveState(s), s));
 5}
 6chrome.storage.onChanged.addListener((c, area) => { if (area === "local" && c.syncStatus) render(); });
 7document.addEventListener("visibilitychange", () => {
 8  clearInterval(timer);
 9  if (!document.hidden) { render(); timer = setInterval(render, 30_000); }
10});
11render(); timer = setInterval(render, 30_000);

Execution context: the popup or options page. Storage changes cover state transitions; the thirty-second interval keeps relative times (“12 minutes ago”) from going stale while the page stays open. Popups close quickly, but options pages and side panels can stay open for hours.

6. Offer “Sync now” without breaking the schedule

 1// popup.js
 2syncNowBtn.addEventListener("click", async () => {
 3  syncNowBtn.disabled = true;
 4  await chrome.runtime.sendMessage({ type: "sync:now" });
 5});
 6
 7// sw.js
 8chrome.runtime.onMessage.addListener((m) => {
 9  if (m?.type !== "sync:now") return;
10  runSync("manual").finally(() => chrome.alarms.create("sync", { periodInMinutes: 60 }));
11});

Execution context: the popup sends; the worker runs. Re-creating the periodic alarm after a manual run restarts its period, so the next automatic sync is a full period later rather than moments after the manual one. Disable the button while a run is in progress (the running state) to avoid duplicate runs.

Common mistakes

  • Showing only the next run. Users care more about the last successful sync.
  • Trusting scheduledTime as exact. Phrase it as “next sync in about 45 minutes” or “around 14:30”.
  • Spinners that never stop. A worker that died mid-job leaves running: true; age it out.
  • Hard-coded English time strings. Use Intl.RelativeTimeFormat with the UI language.
  • Hiding the unscheduled state. A missing alarm is a real bug; show it with a repair action.

Cross-browser variation

  • Chrome / Edge: alarms.get from extension pages; scheduledTime respects clamping.
  • Firefox: same API; scheduledTime is close to actual firing.
  • Safari: scheduledTime may be optimistic because Safari delays alarms; phrase next-run times loosely and rely on last-run data.

Verification

  1. Open the popup after a successful sync: “Synced just now · next in about 60 minutes”.
  2. Break the network and trigger a run: “Last attempt failed” with a retry time.
  3. Clear the alarm from the worker console and reopen the popup: “Sync is not scheduled” with a repair button.
  4. Switch the browser language and confirm relative times are localised.

FAQ

Can a content script show sync status?

It cannot call chrome.alarms, but it can read syncStatus from chrome.storage.local and show “last synced” text.

Should the badge show sync state?

Only for failures that need attention. A permanently changing badge becomes noise. See badge text, colour and count patterns.

How do I show progress for a long sync?

Store processed and total in the status record and render a progress bar, as described in persisting job progress across worker restarts.

Should the options page show a sync history?

A short history — the last ten runs with time, trigger, duration and outcome — is invaluable for support. Keep it in chrome.storage.local as a capped list written by the job, and render it behind a “Details” disclosure so it does not clutter the page for users who never need it. When a user reports “it stopped syncing on Tuesday”, the history answers in seconds.

Other Core APIs & Cross-Browser Data Management Resources