Detecting Tab URL Changes Reliably in MV3

Track SPA navigation and page URL changes from a Manifest V3 service worker using tabs.onUpdated changeInfo.url, webNavigation events, debouncing, and host-permission gating.

Published June 19, 2026 Updated June 19, 2026 8 min read
Table of Contents

The classic pattern — listening for tabs.onUpdated and checking changeInfo.status === "complete" — silently misses every navigation on a single-page application. SPAs like Gmail, GitHub, and most modern dashboards push new history states with the History API without reloading the page, so the browser never fires a second status: "complete". Your listener runs once on initial load and then goes silent. This guide is part of the Tabs API & Window Management reference.

Root cause: status vs url in changeInfo

tabs.onUpdated fires many times per navigation cycle with a changeInfo object that only includes the fields that actually changed on that particular event. A status: "loading" event does not include url. A status: "complete" event does not include url either — by that point the URL change was already reported in an earlier event. And for SPA pushState navigations, there is no loading/complete cycle at all: the only signal Chrome emits is a changeInfo.url field on a dedicated event.

Traditional page load:
  onUpdated { status: "loading", url: "https://example.com/new" }
  onUpdated { status: "complete" }   ← NO url here

SPA pushState navigation:
  onUpdated { url: "https://example.com/new" }  ← url only, no status change

This means filtering on changeInfo.status === "complete" catches every full-page load but misses every SPA navigation. Filtering on changeInfo.url catches both — provided the extension has the "tabs" permission or the appropriate host permissions.

Step-by-step solution

Step 1: filter on changeInfo.url, not changeInfo.status

1// background.ts — registered at module top-level
2chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
3  // Only process events where the URL actually changed
4  if (!changeInfo.url) return;
5
6  console.log(`Tab ${tabId} navigated to: ${changeInfo.url}`);
7  handleNavigation(tabId, changeInfo.url, tab);
8});

Execution context: Service worker, registered synchronously at module top-level. The listener fires for every tab the extension has URL access to. changeInfo.url is undefined on most onUpdated events (those reporting status, title, favIcon, or muted state); filtering on it early avoids redundant processing. Firefox fires onUpdated with url populated on both full-page loads and pushState changes when "tabs" is declared. Safari fires it on full-page loads but may delay or omit it on pushState navigations.

Step 2: declare the required permission

changeInfo.url is gated behind the same permission as tab.url. Without "tabs" or a matching host permission, changeInfo.url is always undefined — and you will never see URL change events.

1{
2  "manifest_version": 3,
3  "name": "Nav Tracker",
4  "permissions": ["tabs"],           // grants changeInfo.url for all tabs
5  "host_permissions": [              // alternative: scope to specific origins
6    "*://*.example.com/*",
7    "*://*.github.com/*"
8  ]
9}

Execution context: manifest.json, evaluated at install and extension-reload time. Using "tabs" grants URL change visibility for every tab the user has open, which Chrome Web Store reviewers flag as a sensitive permission. If your extension only needs to track navigation on specific sites, use host permissions instead — changeInfo.url is then only populated for those origins.

Step 3: debounce rapid URL changes

SPAs sometimes fire multiple onUpdated events in rapid succession — a redirect chain, query-string updates, or hash changes can trigger three or four URL events within milliseconds. Process each change naively and you will flood your storage or send duplicate messages.

 1// Debounce map keyed by tab ID — stored in service-worker module scope
 2// Note: this resets on worker restart; for persistence use chrome.storage.session
 3const pendingDebounce = new Map<number, ReturnType<typeof setTimeout>>();
 4
 5function debouncedNavigation(tabId: number, url: string, delayMs = 300): void {
 6  const existing = pendingDebounce.get(tabId);
 7  if (existing) clearTimeout(existing);
 8
 9  const timer = setTimeout(() => {
10    pendingDebounce.delete(tabId);
11    processNavigation(tabId, url);
12  }, delayMs);
13
14  pendingDebounce.set(tabId, timer);
15}
16
17chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
18  if (!changeInfo.url) return;
19  debouncedNavigation(tabId, changeInfo.url);
20});
21
22function processNavigation(tabId: number, url: string): void {
23  // Only reached once per navigation cluster, ~300 ms after the last URL event
24  chrome.storage.local.set({ [`lastUrl_${tabId}`]: url });
25}

Execution context: Service worker. setTimeout is available in MV3 service workers (unlike setInterval for long-running tasks). The Map lives in module scope and will be cleared if the service worker is evicted — this is acceptable for debouncing (worst case: a burst of events is processed without debouncing once after restart). Firefox and Edge support setTimeout identically. Safari’s service worker may not fire the debounced callback if the worker is evicted before the 300 ms elapses; for high-reliability tracking, use chrome.alarms for the deferred action.

Step 4: add webNavigation for deeper SPA coverage

tabs.onUpdated with changeInfo.url covers the majority of navigations, but some SPA frameworks update the URL without triggering a popstate or pushState in a way Chrome reports. The webNavigation API provides finer-grained events at every step of the navigation pipeline.

 1// Requires "webNavigation" permission in manifest.json
 2chrome.webNavigation.onHistoryStateUpdated.addListener(({ tabId, url, frameId }) => {
 3  if (frameId !== 0) return;  // ignore subframe navigations
 4  console.log(`SPA history update in tab ${tabId}: ${url}`);
 5  processNavigation(tabId, url);
 6});
 7
 8chrome.webNavigation.onCommitted.addListener(({ tabId, url, frameId, transitionType }) => {
 9  if (frameId !== 0) return;
10  // transitionType: "link", "typed", "auto_subframe", "reload", etc.
11  if (transitionType === "reload") return;  // ignore refreshes if not relevant
12  processNavigation(tabId, url);
13});

Execution context: Service worker. webNavigation.onHistoryStateUpdated fires for pushState/replaceState navigations. webNavigation.onCommitted fires for full-page loads and back/forward navigations. The frameId === 0 guard restricts handling to the main frame. Both events require the "webNavigation" permission, which is not sensitive and does not generate an install warning. Firefox supports browser.webNavigation with identical semantics. Safari supports webNavigation on macOS Safari 15.4+; older versions may not fire onHistoryStateUpdated reliably.

Adding the "webNavigation" permission to manifest.json:

1{
2  "manifest_version": 3,
3  "permissions": ["webNavigation"],
4  "host_permissions": ["*://*.example.com/*"]
5}

Execution context: manifest.json. "webNavigation" is a non-sensitive permission with no install-time warning. Host permissions still gate which origins the extension receives navigation events for when using onHistoryStateUpdated with an url filter.

Step 5: combine both approaches with deduplication

Using both tabs.onUpdated and webNavigation can result in duplicate calls for the same navigation. Track the last-processed URL per tab to suppress duplicates.

 1const lastProcessedUrl = new Map<number, string>();
 2
 3function handleUrlChange(tabId: number, url: string): void {
 4  const prev = lastProcessedUrl.get(tabId);
 5  if (prev === url) return;  // same URL — probably a duplicate event
 6  lastProcessedUrl.set(tabId, url);
 7  processNavigation(tabId, url);
 8}
 9
10chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
11  if (changeInfo.url) handleUrlChange(tabId, changeInfo.url);
12});
13
14chrome.webNavigation.onHistoryStateUpdated.addListener(({ tabId, url, frameId }) => {
15  if (frameId === 0) handleUrlChange(tabId, url);
16});
17
18// Clean up when a tab closes to prevent the Map from growing unbounded
19chrome.tabs.onRemoved.addListener((tabId) => {
20  lastProcessedUrl.delete(tabId);
21  pendingDebounce.delete(tabId);
22});

Execution context: Service worker. The Map is module-scoped and cleared on worker restart. For persistence across restarts, write the last URL to chrome.storage.session (Chrome 102+). The onRemoved listener prevents memory growth in long-running sessions with many tab open/close cycles.

Cross-browser variation

  • Chrome / Edge: changeInfo.url fires on full-page loads and pushState navigations. webNavigation.onHistoryStateUpdated is the most reliable SPA signal. Hash-only changes (#section) do not appear in changeInfo.url but do fire webNavigation.onReferenceFragmentUpdated.
  • Firefox: browser.tabs.onUpdated fires changeInfo.url for both full loads and SPA pushState changes when "tabs" is declared — often more reliably than Chrome without needing webNavigation. Use browser.webNavigation for parity. Firefox MV3 has browser.* native Promises; prefer those over chrome.* aliases.
  • Safari: tabs.onUpdated reports URL changes on full-page loads. onHistoryStateUpdated is supported on macOS Safari 15.4+ but may miss rapid pushState sequences. Safari extensions on iOS have additional process-isolation constraints that can delay event delivery by up to one event loop tick.

Verification

  1. Install the extension with "tabs" or a host permission covering the test site.
  2. Open the service-worker DevTools console via chrome://extensions → “Service worker” → Inspect.
  3. Navigate to a known SPA (e.g., GitHub). Click between repository pages.
  4. Confirm console.log entries appear for each in-app navigation — not just the first page load.
  5. Open DevTools → Network tab on the SPA. Confirm the navigations are not full-page reloads (no document request). If your listener still fires, changeInfo.url is working for pushState.
  6. To verify webNavigation, add a temporary console.log inside onHistoryStateUpdated and repeat the test.

FAQ

Why does my listener fire on the first page load but never again on a SPA?

You are filtering on changeInfo.status === "complete", which only fires on full-page loads. Switch to filtering on changeInfo.url for every navigation type.

changeInfo.url is always undefined. I see onUpdated firing but with no URL.

The "tabs" permission or a matching host permission is missing from manifest.json. Without one of these, Chrome omits changeInfo.url entirely. Add the permission, reload the extension, and re-test.

Do I need both tabs.onUpdated and webNavigation?

For most sites, changeInfo.url is sufficient. Add webNavigation.onHistoryStateUpdated when you need to handle frameworks that use history.replaceState without triggering tabs.onUpdated, or when you need transition-type metadata.

How do I track hash-only changes (example.com/page#section)?

changeInfo.url does not fire for fragment-only changes. Use webNavigation.onReferenceFragmentUpdated instead. Alternatively, inject a content script that listens to window.addEventListener("hashchange") and messages the service worker.

Other Core APIs & Cross-Browser Data Management Resources