The log said the sync was scheduled. Two minutes later, nothing had run. No error, no rejected promise, no missed callback warning — just a line that promised future work and a database that never changed.

Here is the excerpt that ended a week of blaming my own debounce logic:

[10:41:02] change detected [10:41:02] sync scheduled in 120000ms [10:41:32] service worker terminated (idle) 

The timer did not fail. The process that owned it was shut down, and a setTimeout cannot outlive its process.

The short version

  • setTimeout is a handle in the worker's heap. Terminate the worker and the handle is gone.
  • MV3 kills an idle background worker after about 30 seconds, no matter what is pending.
  • Persist the due time, not the timer, and re-arm it when the worker wakes.

Three suspects that were innocent

The debounce. The code coalesced rapid changes into one sync two minutes later. It worked in tests because I kept clicking things, which kept the worker alive. In production, users typed once and walked away.

A stale closure. I rewrote the callback twice looking for a captured variable. The closure was fine. It simply never executed.

Storage quota. I added error handling around chrome.storage and saw nothing, because nothing was written. The write happened inside the callback that never ran.

The pattern behind all three: I was debugging JavaScript semantics when the failure was in the execution model.

What MV3 actually guarantees

Chrome keeps a background service worker alive while it is handling events. When the event queue goes quiet for roughly 30 seconds, the worker is terminated. A single event may run for up to five minutes, but pending timers do not count as activity.

That means a timer longer than the idle window is a coin flip. It survives only if something else — a message, a click, an API call — keeps waking the worker before the reaper arrives. In a quiet extension, nothing does.

Fix 1: store the deadline, arm an alarm

This is the default answer and it covers most features:

const KEY = 'pending-sync-at'; const ALARM = 'pending-sync'; async function scheduleSync(delayMs: number) { const dueAt = Date.now() + delayMs; await chrome.storage.local.set({ [KEY]: dueAt }); await chrome.alarms.create(ALARM, { when: dueAt }); } chrome.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name !== ALARM) return; const stored = await chrome.storage.local.get(KEY); if (!stored[KEY]) return; await chrome.storage.local.remove(KEY); await runSync(); }); chrome.runtime.onStartup.addListener(() => void recover()); 

Two details matter. alarms.create({ when }) takes an absolute timestamp, so a worker restart does not shift the deadline. And the alarm handler clears the stored key before running the work, which prevents a duplicate run if the alarm fires twice.

Fix 2: run the delay where the user already is

If the sync only matters while a tab is open, move the debounce into the content script. The page owns that timer, the worker is not involved, and closing the tab cancels work that no longer has a reason to exist. This is not a workaround; it is the right lifetime for UI-scoped work.

Fix 3: keep the worker warm (and accept the cost)

A message every 20 seconds keeps the idle timer from expiring. It also burns battery, keeps a process alive for no user benefit, and is the first thing a Chrome Web Store reviewer asks about. I have used it for a short migration window. I would not ship it as architecture.

Fix 4: move the plumbing into the build

If the codebase has dozens of timer call sites, rewriting each one by hand is how regressions multiply. vite-mv3-sw-compile-persist rewrites long setTimeout/setInterval calls into alarm-backed wrappers at build time and restores marked state on wake. It requires Vite 8+, the storage permission and alarms when long timers are present; destructured variables and anonymous callbacks that capture locals are not recoverable, so the source still needs some discipline.

One more nuance: extension API calls reset the idle timer, so a worker that is making a request is not necessarily idle. Do not rely on that as a design, but it explains why a timer sometimes survives a burst of activity and dies in a quiet minute.

With and without the plugin

The before/after from the plugin's README is the whole argument:

Without vite-mv3-sw-compile-persist

let count = 0; chrome.action.onClicked.addListener(async () => { const stored = await chrome.storage.session.get('count'); count = (stored.count as number) ?? 0; count++; await chrome.storage.session.set({ count }); }); chrome.alarms.create('cleanup', { delayInMinutes: 5 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === 'cleanup') cleanup(); }); 

With vite-mv3-sw-compile-persist

let count = 0; chrome.action.onClicked.addListener(() => { count++; }); setTimeout(cleanup, 5 * 60_000); 

The second version is the code you meant to write. The plugin adds restore, dirty tracking, a debounced flush and alarm-backed timers at build time — no runtime import, no wrapper API.

Repository: vite-mv3-sw-compile-persist

If this saved you the afternoon I lost, a star on the repo is a fair trade — and it helps the next person find the explanation before the workaround.

What is the longest delay your extension promises, and what happens to that promise when the worker dies at second 30?