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.
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.
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.
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.
Cross-browser variation
- Chrome / Edge: the Navigation API is the best signal and needs no permission.
world: "MAIN"is supported in both the manifest andexecuteScript, making the history patch straightforward. - Firefox: no Navigation API. Main-world injection goes through the
userScriptsAPI or a script element;browser.webNavigation.onHistoryStateUpdatedis supported and is usually the simplest portable choice. - Safari: no Navigation API and more restricted main-world access.
webNavigationsupport exists but has been less consistent, so a 400 mslocation.hrefpoll is a legitimate last-resort fallback on Safari specifically. - All three:
popstatefires only for back and forward, never forpushState. An extension that listens only topopstatewill appear to work when the user presses Back and not when they click a link.
Verification
- 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.
- Press Back and Forward and confirm the detector fires for both.
- Navigate away and back with a full reload and confirm
initruns once, not twice. - 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.
Related
- Content script run_at timing explained — the first run, before any of this matters.
- Injecting UI with shadow DOM without breaking the page — keeping injected UI alive across views.
- Detecting tab URL changes — the same problem seen from the service worker.
- Content scripts and DOM injection — the parent guide.