Handling Single-Page App Navigation in Content Scripts

Detect soft navigations in SPAs from an MV3 content script — history API patching, the Navigation API, webNavigation.onHistoryStateUpdated, and idempotent re-initialisation.

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

Your content script runs once, on the first page load, and then the user clicks a link and the whole application changes without a navigation event your script can see. run_at never fires again, the DOM you decorated is gone, and the extension appears to work only until the user does anything. Every large site is now a single-page application, so this is not an edge case — it is the default. This guide is part of content scripts and DOM injection.

What a soft navigation actually is

A soft navigation is a history.pushState or replaceState call plus a DOM update. No document is created, no script is re-executed, and window.onload does not fire again. The only reliable signals are the history API itself, the popstate event for back and forward, and — on Chrome — the Navigation API.

Hard navigation against soft navigationA hard navigation creates a new document and re-runs the content script; a soft navigation calls pushState and mutates the DOM with no script re-execution.UserPage appBrowserContent scriptclick a full linknew document → script runsclick an in-app linkhistory.pushState(url)re-render the DOMnothing fi…
Only the top sequence re-runs your script — which is why the bottom one needs a detector.

Step-by-step

1. Make initialisation idempotent first

Before detecting anything, make re-running safe. Every fix below calls init() more often than you expect, sometimes twice for one navigation.

 1let currentKey = null;
 2
 3function init() {
 4  const key = location.pathname + location.search;
 5  if (key === currentKey) return;          // same view, nothing to do
 6  currentKey = key;
 7  teardown();
 8  if (!shouldRunHere(key)) return;
 9  render();
10}

Execution context: the content script’s isolated world. Keying on path plus query rather than on the full URL means a hash change — which many apps use for in-page anchors — does not trigger a pointless re-render.

2. Detect with the Navigation API where it exists

Chrome’s Navigation API gives a single event for every navigation, soft or hard, and is by far the cleanest signal.

1if (globalThis.navigation) {
2  navigation.addEventListener("navigatesuccess", init);
3}

Execution context: the content script, in the isolated world — the Navigation API is exposed there as well as in the main world. Available in Chrome 102+; absent in Firefox and Safari at the time of writing, which is why the fallback below is not optional.

3. Fall back to patching the history API

In the isolated world you cannot see the page’s history.pushState calls, because the isolated world has its own history binding backed by the same session history — patching it there does nothing for calls made by the page. The patch has to run in the main world.

 1// content/history-hook.js — injected into the main world
 2for (const name of ["pushState", "replaceState"]) {
 3  const original = history[name];
 4  history[name] = function (...args) {
 5    const result = original.apply(this, args);
 6    window.dispatchEvent(new CustomEvent("ext:navigated"));
 7    return result;
 8  };
 9}
10window.addEventListener("popstate", () => window.dispatchEvent(new CustomEvent("ext:navigated")));

Execution context: the page’s main world, where the application’s own history object lives. Register it from the manifest with "world": "MAIN", or inject it with chrome.scripting.executeScript({ world: "MAIN" }) — the boundary is described in bridging data between main world and isolated world.

1// content/main.js — isolated world, listening for the bridged event
2window.addEventListener("ext:navigated", init);

Execution context: the content script’s isolated world. CustomEvent on window crosses between the two worlds because they share the same DOM — which is also why the event name should be specific enough not to collide with the page’s own events.

4. Or let the worker tell you

chrome.webNavigation.onHistoryStateUpdated fires in the service worker for exactly this case, needs no main-world injection, and works on all three engines.

1// service worker
2chrome.webNavigation.onHistoryStateUpdated.addListener(
3  ({ tabId, frameId, url }) => chrome.tabs.sendMessage(tabId, { type: "nav", url }, { frameId })
4    .catch(() => {}),
5  { url: [{ hostEquals: "app.example.com" }] }
6);

Execution context: the service worker, registered at the top level. The URL filter is important: without it the worker is woken on every in-app navigation in every tab. The catch swallows the expected rejection when no content script is present in that frame.

5. Wait for the view to actually exist

A navigation event fires when the URL changes, which is before the new view has rendered. Querying immediately finds the old DOM, or nothing.

 1function whenPresent(selector, timeoutMs = 5000) {
 2  return new Promise((resolve) => {
 3    const found = document.querySelector(selector);
 4    if (found) return resolve(found);
 5    const obs = new MutationObserver(() => {
 6      const el = document.querySelector(selector);
 7      if (el) { obs.disconnect(); resolve(el); }
 8    });
 9    obs.observe(document.documentElement, { childList: true, subtree: true });
10    setTimeout(() => { obs.disconnect(); resolve(null); }, timeoutMs);
11  });
12}

Execution context: the content script. The timeout matters: a view that never appears — because the user navigated again, or because the app errored — must not leave an observer running for the life of the tab.

6. Tear down completely between views

1let cleanups = [];
2const onCleanup = (fn) => cleanups.push(fn);
3
4function teardown() {
5  for (const fn of cleanups.splice(0)) { try { fn(); } catch {} }
6}

Execution context: the content script. Collecting teardown functions as you create observers, listeners and injected nodes makes the cleanup exhaustive by construction — the alternative, remembering each one at teardown time, reliably misses the one added last week.

Four soft-navigation detectorsNavigation API, main-world history patching, webNavigation.onHistoryStateUpdated and polling compared on coverage, permissions and cross-browser support.DetectorCatches everythingNeedsFirefox / SafariNavigation APIYesNothingNot availablePatch history in MAINYesworld: MAINFirefox: userScriptsonHistoryStateUpdatedpushState onlywebNavigation permissionSupportedPolling location.hrefYes, lateNothingSupported
Most extensions end up combining the first available of the top three; polling is the fallback of last resort.

The cost of watching, and how to keep it low

Every detector here has a running cost, and on a long-lived tab that cost compounds. A MutationObserver on document.documentElement with subtree: true is called for every DOM change the application makes — on a chat application that can be thousands of callbacks a minute, each one running your selector query.

Three measures keep it affordable.

Scope the observer as tightly as the app allows. Observing a stable container rather than the document root cuts the callback rate by an order of magnitude on most applications.

1const host = document.querySelector("#app-main") ?? document.documentElement;
2observer.observe(host, { childList: true, subtree: true });

Execution context: the content script. The fallback to documentElement matters — a selector that is absent on first run must not leave the feature silently unobserved.

Debounce the handler, not the observer. The callback itself should do almost nothing; schedule the real work.

1let pending = false;
2const obs = new MutationObserver(() => {
3  if (pending) return;
4  pending = true;
5  queueMicrotask(() => { pending = false; maybeInit(); });
6});

Execution context: the content script. queueMicrotask coalesces a burst of mutations from one render into a single check, which is usually the entire win.

Disconnect when the view does not need you. If your feature applies to /inbox and the user is on /settings, there is nothing to watch for until the URL changes again — and the URL change has its own detector.

The measurable version of all this is the page-load and interaction cost described in measuring content script impact on page load; the same trace shows observer callbacks as long tasks if the handler is doing real work.

Observer callbacks per minute on a busy applicationMutationObserver callback counts for a document-root observer, a scoped observer, and a scoped observer with microtask coalescing, measured on a chat application.documentElement, subtree4200 callback…every DOM changeScoped to #app-main780 callbacks…Scoped + microtask coalesced96 callbacks/…about one per renderDisconnected on non-matching view0 callbacks/m…
Scoping and coalescing together turn a per-mutation cost into a per-render one.

Cross-browser variation

  • Chrome / Edge: the Navigation API is the best signal and needs no permission. world: "MAIN" is supported in both the manifest and executeScript, making the history patch straightforward.
  • Firefox: no Navigation API. Main-world injection goes through the userScripts API or a script element; browser.webNavigation.onHistoryStateUpdated is supported and is usually the simplest portable choice.
  • Safari: no Navigation API and more restricted main-world access. webNavigation support exists but has been less consistent, so a 400 ms location.href poll is a legitimate last-resort fallback on Safari specifically.
  • All three: popstate fires only for back and forward, never for pushState. An extension that listens only to popstate will appear to work when the user presses Back and not when they click a link.

Verification

  1. Open the target app, then from the content script’s console:
1let seen = 0;
2window.addEventListener("ext:navigated", () => console.log("nav", ++seen, location.pathname));

Execution context: the page’s DevTools console with the content script’s context selected in the top-left dropdown. Click through five in-app links; you should see exactly five lines, with the correct paths.

  1. Press Back and Forward and confirm the detector fires for both.
  2. Navigate away and back with a full reload and confirm init runs once, not twice.
  3. Watch the element count in DevTools Elements across ten navigations — a growing count means teardown is missing something.

FAQ

Why not just poll location.href?

You can, and on Safari it may be the only option. The cost is latency — up to your poll interval — and a timer running for the life of every matching tab. Prefer an event where one exists.

Does the history patch break the page?

It should not: the patch calls through to the original and returns its result. The risk is a second extension patching the same method, which is fine as long as both call through. Never swallow the return value.

Should I use webNavigation or the main-world patch?

webNavigation if you already have the permission or are shipping cross-browser; the patch if you want zero extra permissions and are Chrome-first. Using both is redundant but harmless once init is idempotent.

Other MV3 Architecture & Extension Lifecycle Resources