Knowing which page you're on inside a single-page app
A content script runs once. It reads the page, works out what it’s looking at, and acts. Then the user clicks a link, the single-page app swaps the view without a full page load, and every fact your script gathered is stale. The URL changed. The content changed. Your code never ran again.
I dealt with this at Pie, an ad blocker with over two million users, where I led a feature that had to behave differently depending on whose video was playing on YouTube or Twitch. Extracting the identity was the easy part. Keeping it correct across client-side navigation was most of the work, and the failure modes below all shipped to production at some point before we understood them.
Server-rendered metadata is only true once
The obvious place to read page identity is server-rendered metadata: meta tags, embedded JSON, structured data. It’s clean, it’s stable, and it’s authoritative — for exactly one URL, the one the user actually landed on. Single-page apps render that metadata on the server and then never touch it again. Navigate in-app and the tags keep describing the first page.
On YouTube, the page-author meta tag never updates on in-app navigation. Our rule was explicit: trust the meta tag only while the current URL still equals the URL the page originally loaded with. The moment those diverge, the tag is a fossil, and the identity has to come from the live DOM — in our case the channel link in the video’s owner row. Two sources, ordered by a staleness condition you can actually check.
Two sources give you two different keys
Switching sources introduces a second problem. The meta tag gave us a human display name. The DOM anchor gave us the URL handle. Those are different strings for the same channel, and neither is derivable from the other. Any lookup table — an allowlist, a partner list, a settings map — has to be keyed under both forms. We learned this from a real bug: matching succeeded when the user landed directly on a page and failed after in-app navigation to the same page, because each path surfaced a different form of the same identity.
The general rule: when identity can arrive from multiple surfaces, enumerate every form each surface produces and key your data under all of them. Deduplicate at write time, not at match time.
Detecting that navigation happened
Some apps tell you. YouTube dispatches its own yt-page-data-updated event after each in-app navigation, and listening for it is the cheapest correct answer. Twitch emits no equivalent, so there we fell back to a throttled MutationObserver on the document body: on each batch of mutations, re-extract the channel handle and compare it to the last known value. Only a change in the extracted identity counts as a navigation.
The diff target matters. An earlier version of that observer watched visible text and had to be replaced. Display text mutates constantly — counters tick, labels localize, overlays come and go — and none of it means the user went anywhere. Visible text is not an identity key. Extract the stable key, diff the key, ignore everything else.
The timing squeeze
Two clocks run against you. The element you need may not exist yet when your script runs at document_start. And the decision you need to make may be required before the page finishes loading — in our case, ad-handling behavior had to be set before any page script ran, but the identity that determined the behavior was only knowable after the page rendered. The information arrives after the deadline.
There are two honest ways out. Act coarsely early and refine: apply a safe default at load, then correct course once the identity resolves. Or force a reload once you know: accept one wasted load, record the decision, and make it correctly from the top on the second load. We shipped the reload path, which adds its own constraint — the reload destroys your content script, so the decision has to survive in the background script or extension storage, not in the page.
Selectors are configuration, not code
Every selector you write against someone else’s app is a liability with someone else’s release schedule. YouTube ships DOM changes weekly. An extension store review cycle to fix a broken selector is days long, and for those days your feature is down. We shipped our DOM selectors as remote configuration with a per-platform kill switch: when a selector broke, we pushed a config update in minutes, and when a platform’s extraction went bad in a way config couldn’t fix, we turned that platform off rather than let the feature misbehave.
Observer lifecycle
A tempting structure is one MutationObserver per selector you care about, each scoped to its own subtree. It reads well and fails in practice. Per-selector observers have to be torn down and reconnected on every navigation, because the subtrees they watched were replaced. Miss a disconnect and you leak observers that fire against detached nodes. Miss a reconnect and you silently drop events until the next full load. One shared observer on the document, multiplexing all the checks and throttled to a sane cadence, survives navigation without any per-navigation ceremony — the document node is the one thing the SPA never replaces.
The shape of the solution
Treat page identity as a computed value with an explicit source order and an explicit invalidation rule. Server metadata is valid while the URL still matches the original load; after that, the DOM is the source. Key lookups under every form the identity can take. Detect navigation from the app’s own events where they exist, from an identity-diffing observer where they don’t. Decide up front whether early decisions get refined or the page gets reloaded, and keep the state somewhere that survives either answer. And ship the selectors as data you can change on a Tuesday, because the host app will.
Questions clients ask
How do I reliably detect SPA navigation from a content script?
Prefer the app’s own navigation events when it emits them — YouTube fires yt-page-data-updated after each in-app navigation. When the app emits nothing usable, run a throttled MutationObserver on the document, re-extract your identity key on each batch of mutations, and act only when the key changes. Watching the URL alone tells you a navigation started, not that the new page’s DOM is ready to read.
Why not just poll every second?
Polling forces a trade between latency and waste, and it still needs the same extraction and diffing logic an observer needs — you save nothing by polling. The real trap is what you compare, not how often: diff a stable identity key such as a URL handle, never visible display text, which changes for reasons that have nothing to do with navigation.
What do I do when the host site changes its DOM and my selectors break?
Ship selectors as remote configuration with a per-platform kill switch instead of hardcoding them. At Pie the DOM selectors lived in a remotely deployed config file because YouTube ships DOM changes weekly and an extension store review cycle to push a fix is a multi-day outage. Also monitor extraction: when your selector starts returning nothing, you want to know before your users tell you.
How do I test SPA navigation flows?
Test each entry path as its own case: direct load of the target page, in-app navigation to it, and in-app navigation away and back. The two paths hand you identity from different sources — server-rendered metadata on direct load, scraped DOM after navigation — and a lookup keyed under only one form will pass the first case and fail the second. Bugs concentrate at the transitions, so test the transitions, not just the destinations.
Porting an extension that has to track SPA state?
Page-identity code is exactly the kind of logic that breaks quietly in a Safari port. My port assessment maps how your extension detects and tracks page state, which parts carry over to Safari, and which need redesign — a fixed $2,500, credited toward the follow-on work.