Deferring Heavy Jobs Until the Browser Is Idle
Run expensive MV3 background work only when the user is away: chrome.idle states and detection intervals, combining idle events with alarms, deadlines so jobs still run, and Firefox and Safari behaviour.
Table of Contents
A nightly re-index of the user’s saved articles, a large sync, an image cache rebuild — work that takes tens of seconds of CPU and network is fine at 3 a.m. and unwelcome in the middle of a video call. Scheduling it with a fixed alarm runs it whenever the alarm fires, which is often exactly when the user is busy. The chrome.idle API tells the extension when the machine has been unused for a while or is locked, and combining it with alarms lets heavy jobs wait for a quiet moment without ever being skipped forever. This guide sits under alarms and scheduled background jobs.
How idle detection works
chrome.idle reports one of three states: "active" when there has been keyboard or mouse input within the detection interval, "idle" when there has not, and "locked" when the screen is locked. The interval defaults to 60 seconds and can be set as low as 15. chrome.idle.queryState(seconds) answers “has there been input in the last N seconds?” on demand, and chrome.idle.onStateChanged fires whenever the state changes — waking an evicted service worker like any extension event. The state is system-wide: it reflects input anywhere on the machine, not just in the browser, which is exactly the signal a heavy background job wants. What idle detection cannot do on its own is guarantee the job runs at all — a machine in constant use is never idle — so it has to be paired with a deadline.
Step-by-step: idle-aware scheduling
1. Declare the permission
1{ "permissions": ["idle", "alarms", "storage"] }
Execution context: the manifest. idle produces no install warning. Without it, chrome.idle is undefined. Firefox supports the same permission and API; Safari does not implement idle, which step 6 handles.
2. Mark the job due with an alarm instead of running it
1// sw.js
2chrome.runtime.onInstalled.addListener(() => {
3 chrome.alarms.create("reindex-due", { periodInMinutes: 24 * 60 });
4 chrome.idle.setDetectionInterval(120); // "idle" = 2 minutes without input
5});
6
7chrome.alarms.onAlarm.addListener(async ({ name }) => {
8 if (name !== "reindex-due") return;
9 await chrome.storage.local.set({ reindex: { dueAt: Date.now(), deadline: Date.now() + 6 * 3600_000 } });
10 await tryRunHeavyJob("alarm");
11});
Execution context: the service worker, with listeners at the top level. The alarm no longer means “run now”; it means “the job is due”. Persisting the due record and deadline in chrome.storage.local lets any later event — an idle transition, a startup, the deadline alarm — decide to run it. setDetectionInterval persists for the extension but call it on install so it is set before the first idle event.
3. Run when the machine goes idle
1chrome.idle.onStateChanged.addListener((state) => {
2 if (state === "idle" || state === "locked") tryRunHeavyJob(`idle:${state}`);
3});
4
5async function tryRunHeavyJob(trigger) {
6 const { reindex } = await chrome.storage.local.get("reindex");
7 if (!reindex) return; // nothing due
8 const state = await chrome.idle.queryState(120);
9 const pastDeadline = Date.now() >= reindex.deadline;
10 if (state === "active" && !pastDeadline) return; // wait for a quiet moment
11 await runReindex(trigger);
12 await chrome.storage.local.remove("reindex");
13}
Execution context: the service worker. The listener must be registered at the top level so the idle transition wakes an evicted worker. Re-checking with queryState inside tryRunHeavyJob guards against stale triggers — the user may have come back between the event and the check. "locked" is usually the best time of all: the user has explicitly walked away.
4. Enforce the deadline with a second alarm
1async function scheduleDeadline(deadline) {
2 await chrome.alarms.create("reindex-deadline", { when: deadline });
3}
4
5chrome.alarms.onAlarm.addListener(({ name }) => {
6 if (name === "reindex-deadline") tryRunHeavyJob("deadline");
7});
Execution context: the service worker. Call scheduleDeadline when the job becomes due. A user who works for ten hours straight never produces an idle event, and the deadline alarm guarantees the job runs anyway — at a time of your choosing rather than never. Choose a deadline that matches how stale the data may become; a search index can wait six hours, a security list should not.
5. Abort if the user returns mid-job
1async function runReindex(trigger) {
2 const ctrl = new AbortController();
3 const onBack = (s) => { if (s === "active") ctrl.abort(); };
4 chrome.idle.onStateChanged.addListener(onBack);
5 try {
6 for await (const batch of batches()) {
7 if (ctrl.signal.aborted) return saveCursor(batch.cursor); // resume later
8 await indexBatch(batch);
9 }
10 } finally {
11 chrome.idle.onStateChanged.removeListener(onBack);
12 }
13}
Execution context: the service worker. Removing a listener inside the worker is fine for a temporary one like this; the top-level listener from step 3 remains. Processing in batches with a saved cursor makes the job interruptible: when the user returns, the job stops at a batch boundary and resumes at the next idle period, as described in persisting job progress across worker restarts. Unless the deadline has passed, a returning user should get their CPU back.
6. Fall back where idle is unavailable
1const hasIdle = typeof chrome.idle?.queryState === "function";
2async function isQuietNow() {
3 if (hasIdle) return (await chrome.idle.queryState(120)) !== "active";
4 const hour = new Date().getHours();
5 return hour >= 1 && hour < 6; // Safari: assume night hours are quiet
6}
Execution context: any background context. Safari lacks chrome.idle, so a time-of-day heuristic plus the deadline is the practical substitute. On every engine, also consider deferring heavy work while on battery: the Battery Status API is not available in workers, so if it matters, check it from an extension page and store the result.
Common mistakes
- Running heavy work directly from a daily alarm. It fires at whatever time it was created plus 24 hours, which is often a busy time.
- Waiting for idle with no deadline. Machines in constant use never go idle, and the job never runs.
- Registering
onStateChangedlazily. A late listener misses the very transition that woke the worker. - Very short detection intervals. Fifteen seconds of no input is not “away” — someone reading a page qualifies. Two to five minutes is more honest.
- Non-interruptible jobs. A forty-second job that cannot stop makes the first minute back at the keyboard sluggish.
Cross-browser variation
- Chrome / Edge:
idlewithactive,idleandlocked; minimum detection interval 15 seconds.onStateChangedwakes the service worker. - Firefox: supports
browser.idlewith the same states;lockedis reported on most desktop platforms but not all Linux environments. - Safari: no
idleAPI. Use a time-of-day window plus the deadline alarm, and keep jobs small enough to run during normal use.
Verification
- Set the detection interval to 15 seconds in a development build, mark the job due, and leave the keyboard alone: the job should start within about 15 seconds.
- Move the mouse during the job and confirm it stops at a batch boundary and saves its cursor.
- Lock the screen and confirm the job resumes.
- Keep using the machine past the deadline and confirm the deadline alarm runs it.
1await chrome.idle.queryState(15); // "active" | "idle" | "locked"
2await chrome.alarms.getAll(); // reindex-due and reindex-deadline present
Execution context: the service worker console. Note that interacting with DevTools counts as activity.
FAQ
Does idle mean the browser is idle or the computer?
The computer. Input in any application resets it. That is usually what you want for heavy work, since the user’s attention is the scarce resource.
Can I detect that the user is in a video call?
No. Idle detection sees only input. A user watching a video without touching anything appears idle; keep jobs interruptible and light on network bandwidth if that matters.
Does onStateChanged fire while the browser is closed?
No. If the job is due when the browser starts, onStartup should call tryRunHeavyJob, which will run it if idle or past the deadline.
Related
- Persisting job progress across worker restarts — making heavy jobs interruptible.
- Scheduling daily and weekly syncs — when the job becomes due.
- Keeping service workers alive during long tasks — limits on how long a job may run.
- Alarms and scheduled background jobs — the parent topic.