Minimum Alarm Period and Throttling
Why a sub-minute chrome.alarms period works unpacked and is clamped for users, how the first delay differs from the repeat period, and what to use when you genuinely need faster ticks.
Table of Contents
- Root cause: the clamp applies to packed extensions only
- Step 1. Separate the first delay from the repeat period
- Step 2. Clamp on your own side so both builds agree
- Step 3. Use a self-rescheduling one-shot only when you mean it
- Step 4. Prefer an event to a poll
- Step 5. Measure what the cadence actually costs
- Cross-browser variation
- Verification
- FAQ
- Related
You set periodInMinutes: 0.25, watch it tick every fifteen seconds in your unpacked build, ship it, and users get one tick a minute. Nothing errored, nothing warned, and getAll reports the period you asked for on your machine and a different one on theirs. This page is part of Alarms & Scheduling Background Jobs and explains the clamp, the one exception to it, and what to do when a minute is genuinely too slow.
Root cause: the clamp applies to packed extensions only
Browsers clamp repeating alarms to a one-minute minimum for installed extensions, and deliberately do not clamp them for unpacked development builds — the reasoning being that a developer iterating on a timer should not wait a minute per iteration. The result is a difference in behaviour between your machine and every user’s, in the direction least likely to be noticed.
Step 1. Separate the first delay from the repeat period
delayInMinutes controls when the alarm first fires; periodInMinutes controls the repeat. Only the repeat is clamped, so a short first delay is a legitimate way to get prompt initial work without asking for a sub-minute cadence.
1// background.js — fire in about six seconds, then settle to a legal cadence
2await chrome.alarms.create("import-chunk", {
3 delayInMinutes: 0.1, // first firing: not subject to the repeat clamp
4 periodInMinutes: 1, // repeat: the minimum the browser will honour
5});
Execution context: Extension service worker. This is the shape used by the chunked-job pattern: the first chunk starts almost immediately so the user sees progress, and subsequent chunks run at the fastest legal cadence. Asking for periodInMinutes: 0.1 here would change nothing for users and would make the development build behave differently from production, which is worse than the honest one-minute value.
Step 2. Clamp on your own side so both builds agree
The most valuable thing you can do about the clamp is apply it yourself. A development build that behaves like production is worth more than a fast feedback loop on a timer.
1// scheduling.js
2export const MIN_PERIOD_MINUTES = 1;
3
4export async function schedule(name, requestedMinutes, { firstDelay = 0.1 } = {}) {
5 const periodInMinutes = Math.max(MIN_PERIOD_MINUTES, Number(requestedMinutes) || MIN_PERIOD_MINUTES);
6
7 if (periodInMinutes !== Number(requestedMinutes)) {
8 console.warn(`alarm "${name}": ${requestedMinutes} min clamped to ${periodInMinutes} min`);
9 }
10
11 await chrome.alarms.create(name, { delayInMinutes: firstDelay, periodInMinutes });
12 return periodInMinutes;
13}
Execution context: Shared scheduling module imported by the service worker. The warning is the useful part: it turns a silent divergence into a line in the console during development, at the moment the wrong value is requested rather than weeks later in a user report. Returning the effective period lets callers display or log a cadence that matches reality.
Step 3. Use a self-rescheduling one-shot only when you mean it
A one-shot alarm recreated at the end of each run is not clamped the way a repeat period is — but each firing is still a full worker wake, so a fifteen-second chain means four cold starts a minute for as long as it runs. That is acceptable for a bounded job with a visible end, and unacceptable as a permanent background cadence.
1// background.js — a bounded fast chain, with a hard stop
2const FAST_TICK_MINUTES = 0.25;
3const MAX_FAST_TICKS = 40; // about ten minutes, then stop
4
5chrome.alarms.onAlarm.addListener((alarm) => {
6 if (alarm.name !== "fast-tick") return;
7
8 (async () => {
9 const { fastTicks = 0 } = await chrome.storage.session.get("fastTicks");
10 const done = await advanceJob();
11
12 if (done || fastTicks + 1 >= MAX_FAST_TICKS) {
13 await chrome.alarms.clear("fast-tick");
14 await chrome.storage.session.remove("fastTicks");
15 return;
16 }
17
18 await chrome.storage.session.set({ fastTicks: fastTicks + 1 });
19 await chrome.alarms.create("fast-tick", { delayInMinutes: FAST_TICK_MINUTES });
20 })();
21});
Execution context: Extension service worker. Counting the ticks in storage.session rather than module scope is what makes the hard stop reliable across evictions — without it, an eviction resets the counter and the chain runs forever. The hard stop is not optional: a fast chain with no termination condition is the pattern that gets an extension flagged for battery use.
Step 4. Prefer an event to a poll
Most sub-minute polling exists because something else was hard to observe. Before building a fast chain, check whether the thing you are polling for has an event: tab updates, storage changes, network rules matching, downloads, idle state and window focus all do.
The last two rows are the honest cases for an alarm, and both are well served by a period measured in minutes. A deadline in particular should be a one-shot alarm scheduled with when at the exact time, not a poll that checks whether the moment has arrived — the browser is better at waiting than your handler is.
Step 5. Measure what the cadence actually costs
Before defending a fast cadence, price it. Each firing is a full cold start, so the cost of a schedule is roughly the cold-start cost times the number of firings per hour — and that number is easy to get wrong by an order of magnitude when reasoning about it rather than measuring.
1// background.js — a temporary instrument, removed before shipping
2chrome.alarms.onAlarm.addListener((alarm) => {
3 (async () => {
4 const key = `stats:${alarm.name}`;
5 const { [key]: stats = { firings: 0, since: Date.now() } } = await chrome.storage.session.get(key);
6 const next = { firings: stats.firings + 1, since: stats.since };
7 await chrome.storage.session.set({ [key]: next });
8
9 const hours = (Date.now() - next.since) / 3_600_000;
10 if (hours > 0.05) {
11 console.info(`${alarm.name}: ${(next.firings / hours).toFixed(1)} firings/hour`);
12 }
13 })();
14});
Execution context: Extension service worker, during development only. Counting in storage.session keeps the figure meaningful across evictions within a browsing session and discards it on restart, which is the right window for this measurement. A one-minute alarm is sixty cold starts an hour; multiply by the cold-start figure from your own profile and the answer usually settles the question of whether the cadence is justified — see reducing service worker cold start latency for how to lower the per-firing cost when the cadence genuinely cannot move.
Cross-browser variation
- Chrome 120+: repeating alarms clamp to one minute for installed extensions; unpacked builds are exempt.
delayInMinutesfor the first firing is not clamped in the same way. - Firefox 121+: applies the same one-minute floor under MV3. Its more forgiving background context can mask the cost of a fast chain, so measure wake frequency in Chrome.
- Safari 17+: clamps at least as aggressively and adds power-state throttling, so a one-minute alarm may effectively deliver less often on battery.
- All engines: the clamp is silent. Nothing throws, nothing warns, and
getAllmay echo back the period you requested — which is why the client-side clamp in step 2 earns its place.
Verification
- Set
periodInMinutes: 0.25, load the extension unpacked, and logDate.now()per firing. Expect roughly fifteen seconds. - Pack the extension, install it from the packed file, and repeat. Expect roughly sixty seconds — the same code, a different cadence.
- Enable the client-side clamp and confirm the console warning names the alarm and both periods.
- Run the fast chain and confirm it stops at the tick cap even when you force an eviction partway through.
FAQ
Why is my sub-minute alarm honoured while developing?
Because unpacked extensions are exempt from the clamp by design, so timer iteration is not painful during development. It is also why the difference is so easy to ship.
Is delayInMinutes clamped too?
The first delay is treated separately from the repeat period, which is why the delay-plus-period shape in step 1 is worth using. Do not rely on an extremely small delay for a cadence, though — use it once, for promptness.
Can I use setInterval for faster ticks?
Only for the lifetime of one wake, and it will disappear with the worker. For a bounded burst inside a single handler that is fine; as a background cadence it is the bug that alarms exist to fix.
Does a fast alarm chain hurt battery life?
Yes, measurably, because each firing is a full worker cold start. That is the reason for the hard tick cap — a bounded burst is defensible, a permanent fast chain is not.
Related
- Alarms that don’t fire after browser restart — the other common alarm complaint.
- Alarms & Scheduling Background Jobs — the guide this page belongs to.
- Reducing service worker cold start latency — what each extra firing costs.
- Keeping service workers alive during long tasks — the bounded-burst alternative.