Throttling and Debouncing High-Frequency Events

Tame bursty extension events — tabs.onUpdated, webNavigation, storage.onChanged, scroll and mutation observers — with debounce, throttle and coalescing that survive a service worker being evicted.

Published September 18, 2026 Updated September 18, 2026 7 min read
Table of Contents

Some extension events arrive in floods. tabs.onUpdated fires several times for every page load — loading, title, favicon, complete, each audio state change. Restoring a session fires it for fifty tabs at once. A bookmark import fires onCreated thousands of times. storage.onChanged fires for every write, including your own. Reacting to each one individually wakes the service worker repeatedly, hammers storage, and in content scripts, adds work to every scroll frame. The fix is ordinary debouncing — with one MV3-specific twist: a timer in the worker can die with the worker. This guide is part of performance profiling and optimisation.

The event shapes, and which tool fits

Bursty extension events and how to smooth themFive high-frequency events with how often they fire, the context they arrive in, and whether debounce, throttle or coalescing is the right tool.EventBurst sizeContextTooltabs.onUpdated4–10 per loadWorkerFilter, then debounce per…Session restoreHundreds at onceWorkerCoalesce via alarmstorage.onChangedOne per writeEvery contextDebounce the reactionscroll / resize60+ per secondContent scriptThrottle to a frameMutationObserverHundreds per renderContent scriptCoalesce per microtask
Anything in the worker that must survive eviction needs an alarm or a durable flag, not an in-memory timer.

Step-by-step

1. Filter before you debounce

The cheapest event is the one you never handle. tabs.onUpdated tells you what changed; most changes are irrelevant.

1chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
2  if (info.status !== "complete" && !("url" in info)) return;   // ignore title, favicon, audible…
3  scheduleTabWork(tabId, tab.url);
4});

Execution context: the service worker, registered at the top level. Of the several events a normal page load produces, only one or two carry information most extensions need. On Chrome 110+ you can also pass a filter object — { properties: ["status", "url"] } — so the browser does not even wake the worker for the rest.

2. Debounce per key within one event burst

1const pending = new Map();
2
3function scheduleTabWork(tabId, url) {
4  clearTimeout(pending.get(tabId));
5  pending.set(tabId, setTimeout(() => {
6    pending.delete(tabId);
7    refreshBadgeFor(tabId, url);
8  }, 250));
9}

Execution context: the service worker. A short in-memory timer is safe here because it lives inside a burst of events that keeps the worker awake. Keying the map by tab id means a burst on one tab does not delay work for another. If the worker is evicted with a timer pending, the work is lost — acceptable for a badge that the next event recomputes anyway.

3. Coalesce large bursts with an alarm

For bursts that can outlast the worker — a session restore, a bookmark import — record that work is needed and let an alarm do it once.

 1async function markDirty(kind) {
 2  const { dirty = {} } = await chrome.storage.session.get("dirty");
 3  if (dirty[kind]) return;                          // already scheduled
 4  dirty[kind] = Date.now();
 5  await chrome.storage.session.set({ dirty });
 6  await chrome.alarms.create(`reconcile:${kind}`, { delayInMinutes: 1 });
 7}
 8
 9chrome.alarms.onAlarm.addListener(async (a) => {
10  if (!a.name.startsWith("reconcile:")) return;
11  const kind = a.name.split(":")[1];
12  const { dirty = {} } = await chrome.storage.session.get("dirty");
13  delete dirty[kind];
14  await chrome.storage.session.set({ dirty });
15  await reconcile(kind);
16});

Execution context: the service worker. The dirty flag lives in storage.session, so it survives eviction; the alarm guarantees the reconcile runs even if the worker is evicted a hundred times during the burst. The one-minute floor on alarms is the cost — fine for reconciliation, wrong for anything the user is waiting to see. The same pattern appears in reading and writing bookmarks safely.

4. Stop reacting to your own writes

A storage.onChanged listener that writes storage in response will fire itself. Compare before reacting, and debounce the reaction.

1let applyTimer;
2chrome.storage.onChanged.addListener((changes, area) => {
3  if (area !== "sync" || !changes.settings) return;
4  const { oldValue, newValue } = changes.settings;
5  if (JSON.stringify(oldValue) === JSON.stringify(newValue)) return;   // no real change
6  clearTimeout(applyTimer);
7  applyTimer = setTimeout(() => applySettings(newValue), 100);
8});

Execution context: any extension context with the listener — pages, the worker, content scripts. The equality check stops write loops; the debounce collapses a slider drag into one application. Write-side batching is covered in batching storage writes to stay under quota.

5. Throttle scroll work to animation frames

1let scheduled = false;
2addEventListener("scroll", () => {
3  if (scheduled) return;
4  scheduled = true;
5  requestAnimationFrame(() => {
6    scheduled = false;
7    updateReadingProgress();
8  });
9}, { passive: true });

Execution context: the content script, in the page. passive: true tells the browser the listener will not call preventDefault, so scrolling is never blocked waiting for your code. One update per frame is the most a user can see; anything more is wasted work on the page’s main thread.

6. Coalesce MutationObserver callbacks

1let queued = false;
2new MutationObserver(() => {
3  if (queued) return;
4  queued = true;
5  queueMicrotask(() => { queued = false; scanForTargets(); });
6}).observe(document.body, { childList: true, subtree: true });

Execution context: the content script. A framework render can produce hundreds of mutation records; coalescing to one scan per microtask turns hundreds of scans into one. Scoping the observer to a stable container, rather than body, cuts the volume further — measured in handling single-page app navigation in content scripts.

Session restore with and without coalescingFifty tabs restore in a few seconds, producing hundreds of onUpdated events; filtered and coalesced handling runs one reconcile after the burst instead of hundreds of individual updates.browser start+90 sRestore burst~400 onUpdatedFiltered~50 rele…Quietdirty flag set, alarm pendingOne rec…~200 msIdleworker evictedfirst event marks dirtyalarm fires once
The burst still arrives — only the work it triggers is collapsed.

Choosing delays

Debounce and throttle intervals are product decisions dressed as numbers. Three guidelines keep them sensible.

Match human perception for visible work. Anything the user sees update — a badge, a progress indicator, a highlight — should respond within about 100 ms. Debounce windows longer than that feel laggy; shorter ones save little.

Match the source’s burst length for invisible work. A page load’s onUpdated burst lasts a second or two; a 250 ms debounce per tab collapses most of it. A session restore lasts several seconds; an alarm with its one-minute minimum is fine because nobody is waiting.

Never debounce correctness. A debounce that drops the last event of a burst — rather than the intermediate ones — loses the final state. Trailing-edge debounce, as in the examples above, always runs with the latest value; leading-edge-only debounce does not, and is rarely what an extension needs.

Worker wakes during a 50-tab session restoreService worker invocations attributable to tab-update handling during a session restore, unfiltered, filtered by property, and filtered plus coalesced via alarm.Unfiltered onUpdated410 handler r…Filtered: status/url only95 handler ru…Filtered + per-tab debounce50 handler ru…Filtered + alarm coalescing1 handler runs
Property filtering removes most wakes at the source; the alarm collapses the rest into one.

Cross-browser variation

  • Chrome / Edge: tabs.onUpdated accepts a filter (properties, tabId, urls) from Chrome 110, which prevents irrelevant events from waking the worker at all. Alarms have a one-minute minimum in packed builds.
  • Firefox: supports the same onUpdated filter (it originated there). The event-page background tolerates in-memory timers longer, but the durable pattern is still correct.
  • Safari: event volumes are similar; the background context is evicted sooner, so durable coalescing matters more than in-memory debouncing.
  • All three: requestAnimationFrame and passive listeners behave identically in content scripts.

Verification

  1. Count handler runs during a page load with a temporary counter, before and after adding the filter:
1let runs = 0;
2chrome.tabs.onUpdated.addListener(() => runs++);
3// load one page, then:
4runs;   // e.g. 7 unfiltered → 1–2 filtered

Execution context: the service worker console. The drop confirms the filter is doing its job.

  1. Restore a session with many tabs and confirm the reconcile alarm fires once.
  2. Record a Performance trace while scrolling a page with your content script; scroll handlers should appear at most once per frame.
  3. Drag a slider in the options page and confirm applySettings runs once after the drag ends.

FAQ

Is setTimeout in the worker always wrong?

No — inside a burst of events that keeps the worker awake, a short timer is fine. It is wrong for anything that must happen after the worker might have gone idle.

Should I throttle or debounce a content script’s input handler?

Debounce if you only need the final value (search-as-you-type). Throttle if intermediate values matter (a live preview). For scroll-driven visuals, throttle to animation frames.

Can I filter storage.onChanged at the source?

No — there is no filter parameter. Check the area and key first thing in the listener so irrelevant changes return immediately.

Other Testing, Debugging & Performance Optimization Resources