Alarms vs setTimeout in Service Workers
Why setTimeout and setInterval silently stop working in an MV3 service worker, which delays still work, and how to convert each timer pattern to chrome.alarms.
Table of Contents
A setInterval that fired reliably in your Manifest V2 background page fires once or twice in MV3 and then stops. No exception is thrown, nothing appears in the console, and attaching a debugger makes the problem disappear because an open DevTools session keeps the worker alive. The timer is not broken — the worker that owned it was destroyed, taking every pending timer with it. This guide belongs to alarms and scheduled background jobs.
Root cause: timers live in the worker, alarms live in the browser
setTimeout schedules a callback on the worker’s own event loop. When the service worker is terminated — which happens after roughly thirty idle seconds — its event loop and every queued timer go with it. A pending timer is not an event the browser knows about, so it will not restart the worker to service it.
chrome.alarms is the opposite: the alarm record is stored by the browser, outside any worker, and firing it is an event that wakes a sleeping worker the same way a message or a tab update does. That single difference explains every behaviour developers find surprising here.
When setTimeout is still the right tool
The rule is not “never use setTimeout in a worker”. It is: a timer may only be used for a delay inside an event you are already handling, and only for delays short enough to finish before the worker can be evicted.
1// Fine: a 300 ms debounce inside a single message handler.
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 if (msg.type !== "search") return;
4 const done = new Promise((resolve) => {
5 setTimeout(() => resolve(runSearch(msg.query)), 300);
6 });
7 done.then(sendResponse);
8 return true; // keep the message channel open
9});
Execution context: the service worker. Returning true keeps the channel — and with it the worker — alive until sendResponse runs. Firefox and Safari honour the same contract; Firefox also lets you return the promise directly.
1// Not fine: a timer that outlives the event that created it.
2chrome.runtime.onInstalled.addListener(() => {
3 setInterval(() => syncNow(), 15 * 60 * 1000); // never fires after the first eviction
4});
Execution context: the service worker. onInstalled resolves immediately, the worker goes idle, and the interval is discarded roughly thirty seconds later. Nothing logs an error.
Step-by-step: converting each timer pattern
1. Convert a repeating interval
1// Before (MV2 background page)
2setInterval(syncNow, 15 * 60 * 1000);
3
4// After (MV3 service worker)
5chrome.runtime.onInstalled.addListener(() => {
6 chrome.alarms.create("sync", { periodInMinutes: 15 });
7});
8chrome.runtime.onStartup.addListener(() => {
9 chrome.alarms.create("sync", { periodInMinutes: 15 });
10});
11chrome.alarms.onAlarm.addListener((a) => {
12 if (a.name === "sync") return syncNow();
13});
Execution context: top level of the service worker plus two lifecycle events. Creating an alarm with an existing name replaces it rather than duplicating it, so calling create from both onInstalled and onStartup is safe and idempotent.
2. Convert a one-shot delay that must survive eviction
1// Before
2setTimeout(() => showReminder(), 60 * 60 * 1000);
3
4// After
5chrome.alarms.create("reminder", { delayInMinutes: 60 });
Execution context: the service worker. delayInMinutes under 1 is clamped to 1 in packed builds; the clamping is silent, which is covered in minimum alarm period and throttling.
3. Convert a retry-with-backoff loop
Backoff is the pattern most often smuggled in as a timer, because each retry is “only a few seconds”. Under MV3 the gap between retries is exactly when the worker gets evicted.
1async function attemptUpload(attempt = 0) {
2 try {
3 await upload();
4 await chrome.storage.session.remove("uploadAttempt");
5 } catch {
6 if (attempt >= 5) return;
7 await chrome.storage.session.set({ uploadAttempt: attempt + 1 });
8 const minutes = Math.min(2 ** attempt, 30); // 1, 2, 4, 8, 16, 30
9 chrome.alarms.create("upload-retry", { delayInMinutes: minutes });
10 }
11}
12
13chrome.alarms.onAlarm.addListener(async (a) => {
14 if (a.name !== "upload-retry") return;
15 const { uploadAttempt = 0 } = await chrome.storage.session.get("uploadAttempt");
16 return attemptUpload(uploadAttempt);
17});
Execution context: the service worker. chrome.storage.session is in-memory and cleared when the browser closes — ideal for retry counters you do not want to persist across a restart. Firefox added storage.session in 115; Safari 16.4. Fall back to storage.local where it is missing, as described in chrome.storage.session vs local.
4. Convert a heartbeat used only to keep the worker alive
If the timer exists purely so that the worker stays resident, delete it. That is not a scheduling problem, it is a state problem — move the state to storage and let the worker die. Where a genuinely long unit of work is involved, chain alarm ticks instead, as in chaining alarms for long-running jobs.
The debugging trap: why this bug hides
Almost every report of “my interval stopped” comes with the observation that it works when the developer watches it. That is not coincidence, and understanding why saves hours.
An attached DevTools session pins the service worker: Chrome will not evict a worker that has an open inspector, because doing so would drop the debugging session. So the moment you open the worker’s console to find out why the timer stopped, the condition that caused it disappears. The same is true of any activity that keeps events arriving — moving the mouse over a page with your content script on it, switching tabs with a tabs.onActivated listener registered, or simply having the popup open.
Three techniques make the failure reproducible:
1// 1. Log with a persisted timestamp instead of watching the console.
2async function mark(label) {
3 const { timerLog = [] } = await chrome.storage.local.get("timerLog");
4 timerLog.push({ label, at: Date.now() });
5 await chrome.storage.local.set({ timerLog: timerLog.slice(-50) });
6}
Execution context: the service worker. Storage survives eviction, so the log is readable afterwards from the options page — the only way to see what a worker did in the minutes before it died.
The second technique is the Stop button on chrome://extensions, which terminates the worker on demand. Click it, wait past the interval you expected, and check whether anything fired. The third is to test on Safari, where the eviction is fastest and the failure surfaces within seconds rather than minutes.
There is a fourth trap worth naming: an interval that was created inside an event that is still being handled can appear to work indefinitely, because the worker is being kept alive by a pending promise elsewhere in your code. That is not the interval working — it is the worker not yet being evicted. Remove the unrelated keep-alive and the interval dies with it.
Cross-browser variation
- Chrome / Edge: timers are destroyed on worker termination with no warning. The minimum alarm period in a packed extension is one minute;
chrome.alarmshas no upper bound on the delay. - Firefox: the MV3 background context is an event page, which keeps timers alive longer and can mask the bug during development. Firefox still terminates idle event pages, so the conversion is required for correctness even though the symptom is rarer.
browser.alarmsaccepts sub-minute periods in more cases than Chrome does — do not rely on it. - Safari: timers are terminated aggressively, often well before thirty seconds, and alarm delivery can be deferred when the machine is under power management. Safari is the browser that exposes this bug fastest, which makes it a useful test target.
- All three:
setIntervalinside a content script is unaffected by any of this — content scripts live with the page, not the worker. Background throttling of the tab still applies.
Verification
- Load the extension unpacked and open the service worker DevTools from
chrome://extensions. - Close the DevTools window — an open inspector keeps the worker alive and hides the failure.
- Trigger the code path that used to schedule a timer, then wait at least a minute without interacting with the extension.
- Reopen the service worker DevTools and run:
1await chrome.alarms.getAll();
Execution context: the service worker console. Alarms you created appear here with their scheduledTime; timers do not appear at all, which is the point — there is nothing to inspect because there is nothing left.
- A converted path should show the alarm entry and, after its scheduled time passes, a log line from the alarm handler. An unconverted path shows an empty array and silence.
FAQ
Does await keep the worker alive indefinitely?
No. Chrome extends the worker’s life while a chrome.* API call is in flight and while a promise returned from an event listener is pending, but there is still a hard ceiling of roughly five minutes. An await on a slow fetch is not an unlimited lease.
Why did my interval work for the first few minutes?
Because the worker stayed alive while other events were arriving — a tab update, a message from a content script, your own DevTools session. As soon as the extension went quiet the worker was evicted and the interval went with it.
Can I use chrome.alarms for a delay of ten seconds?
Not in a packed extension: Chrome clamps delayInMinutes to a minimum of one minute. If you need ten seconds and the work is genuinely tied to a live event, use setTimeout inside that event and keep the promise pending. If it is not tied to a live event, a ten-second delay is usually a design smell.
Related
- Minimum alarm period and throttling — the clamping rules that catch out sub-minute schedules.
- Chaining alarms for long-running jobs — the replacement for a keep-alive heartbeat.
- Persistent vs non-persistent service workers explained — the lifecycle model behind the timer loss.
- Alarms and scheduled background jobs — the parent guide for scheduling in MV3.