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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

A heavy job waiting for idle, with a deadlineThe daily alarm marks the job due; the user is active so it waits; the machine goes idle and the job runs; on a day with no idle period the deadline forces it to run anyway.job due (alarm)deadline +6 hUser activejob waitsIdle ≥ 2 minjob runsDonenext due tomorrowonStateChanged → idledeadline: run regardless
Idle decides when; the deadline decides that it always happens.

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.

Due, waiting, then idleThe daily alarm marks the job due while the user is active; later the idle API reports idle, waking the worker, which checks the due record, confirms idle with queryState and runs the job.AlarmsService workerIdle APIstorage.localonAlarm reindex-dueset reindex {dueAt, deadline}queryState → active: waitonStateChanged(idle)queryState → idlerun job, remove reindex
Two independent events cooperate through one record in storage.

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.

Idle states and what to do in eachThe active, idle and locked states, what each means and whether a deferred heavy job should start, continue or pause.StateMeansStart jobRunning jobactiveInput within intervalOnly past deadlinePause at batch boundaryidleNo input for intervalYesContinuelockedScreen lockedYesContinue
Locked is the best time; active is the time to yield.

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 onStateChanged lazily. 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: idle with active, idle and locked; minimum detection interval 15 seconds. onStateChanged wakes the service worker.
  • Firefox: supports browser.idle with the same states; locked is reported on most desktop platforms but not all Linux environments.
  • Safari: no idle API. Use a time-of-day window plus the deadline alarm, and keep jobs small enough to run during normal use.

Verification

  1. 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.
  2. Move the mouse during the job and confirm it stops at a batch boundary and saves its cursor.
  3. Lock the screen and confirm the job resumes.
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources