Single-Page Apps
Episode 6: when the page never reloads, the SDK re-runs the whole pipeline on every URL change and keeps every invariant true
Copy this guide and your current settings as a prompt for your AI agent.
Preview agent promptSee the guide, code and settings included when you copy.
Every episode so far assumed a page load. In a single-page app there is only ever one. The URL changes, the view changes, the visitor feels like they navigated, and none of the browser events that drive episodes 1 to 5 ever fire again. So the SDK has to notice, and re-run everything itself.
On this page
01Two watchers
The SDK installs two watchers, both only when the project enables them
(project.settings.mutation_observer_active): a MutationObserver on
document.body watching the whole subtree, which keeps metrics bound to DOM
that your framework just replaced, and a URL observer, which is the real
navigation signal.
03Rebinding metrics
When your framework swaps the DOM, the elements your metrics were bound to
are gone. Every child-list change schedules rebindMetrics, debounced to
100 ms because a render can fire hundreds of mutations. Rebinding is
idempotent: it re-stamps coframe-metric-ids on whatever is on the page now
and is safe to run as often as it likes.
04The loop guard
The SDK changes the DOM, the observer sees the change, the SDK changes the DOM
again. So there is a counter: after 5,000 mutation callbacks between
navigations the observer disconnects itself and logs
mutation_observer_loop_protection. It resets on every URL change. A runaway
page degrades to no rebinding, never to a frozen tab.
05One frame, then abort
When the URL changes, the SDK waits exactly one animation frame, long enough for the new view to settle. Then it aborts whatever it was fetching. A visitor clicking through three tabs quickly fires three navigations and only the last one is allowed to finish; the abort signal is checked twice more before anything is applied.
06The frame cache
Cache first. Every page’s frames are cached by normalized URL for 60 seconds
(sdk-js/src/frameCache.ts). Back and forward hit that cache and apply
instantly with no network. A miss fetches from the edge and caches the
result. If the fetch fails, the SDK falls back to the project baked into the
bundle, so the page still gets variants.
07Re-running the pipeline
Then it re-runs everything: assignment for this URL (episodes 1 and 4), a fresh session id and the pair written again (episode 5), variants applied, the page revealed. The only thing that does not repeat is the personalization headers: device, country, bot. Those were computed at the edge on the first load and ride along unchanged.
08What stays true
- One pageview, one session, one exposure, even without a page load.
- The same token, so the same bins, so the same seats.
- Consent still gates only sending.
- The reveal still cannot be skipped.
The page never reloaded, and the measurement never noticed.
09Where in the code
| File | What to look for |
|---|---|
sdk/sdk-js/src/init/modifyPage.ts |
setupSPANavigation, the 100 ms debounce, the 5,000-callback guard, observeUrlChanges handling with AbortController |
sdk/sdk-js/src/router.ts |
observeUrlChanges, the history patches, urlsMatch |
sdk/sdk-js/src/frameCache.ts |
CACHE_TTL_MS, getCachedFrames, cacheFrames |
sdk/sdk-js/src/metrics.ts |
rebindMetrics |
# Single-Page Apps Episode 6: when the page never reloads, the SDK re-runs the whole pipeline on every URL change and keeps every invariant true Every episode so far assumed a page load. In a single-page app there is only ever one. The URL changes, the view changes, the visitor feels like they navigated, and none of the browser events that drive episodes 1 to 5 ever fire again. So the SDK has to notice, and re-run everything itself. [Video: Video: Runtime Walkthrough, episode 6: two watchers, catching the navigation, rebinding metrics, the loop guard, one frame then abort, the frame cache, and re-running the pipeline (3m 31s, narrated)](assets/videos/sdk-internals-06-spa.mp4) Watch: 3m 31s — Runtime Walkthrough, episode 6: two watchers, catching the navigation, rebinding metrics, the loop guard, one frame then abort, the frame cache, and re-running the pipeline ## Two watchers The SDK installs two watchers, both only when the project enables them (`project.settings.mutation_observer_active`): a `MutationObserver` on `document.body` watching the whole subtree, which keeps metrics bound to DOM that your framework just replaced, and a URL observer, which is the real navigation signal. ## Catching the navigation There is no navigation event in a single-page app, so the SDK makes one (`sdk-js/src/router.ts`). It patches `history.pushState` and `replaceState`, calling the original first and checking the URL on the next tick. It listens for `popstate` (the back button) and `hashchange`. Four hooks, one question: did the URL actually change? Even a query parameter counts: `?step=1` to `?step=2` is a navigation. ## Rebinding metrics When your framework swaps the DOM, the elements your metrics were bound to are gone. Every child-list change schedules `rebindMetrics`, debounced to 100 ms because a render can fire hundreds of mutations. Rebinding is idempotent: it re-stamps `coframe-metric-ids` on whatever is on the page now and is safe to run as often as it likes. ## The loop guard The SDK changes the DOM, the observer sees the change, the SDK changes the DOM again. So there is a counter: after 5,000 mutation callbacks between navigations the observer disconnects itself and logs `mutation_observer_loop_protection`. It resets on every URL change. A runaway page degrades to no rebinding, never to a frozen tab. ## One frame, then abort When the URL changes, the SDK waits exactly one animation frame, long enough for the new view to settle. Then it aborts whatever it was fetching. A visitor clicking through three tabs quickly fires three navigations and only the last one is allowed to finish; the abort signal is checked twice more before anything is applied. ## The frame cache Cache first. Every page’s frames are cached by normalized URL for 60 seconds (`sdk-js/src/frameCache.ts`). Back and forward hit that cache and apply instantly with no network. A miss fetches from the edge and caches the result. If the fetch fails, the SDK falls back to the project baked into the bundle, so the page still gets variants. ## Re-running the pipeline Then it re-runs everything: assignment for this URL (episodes 1 and 4), a fresh session id and the pair written again (episode 5), variants applied, the page revealed. The only thing that does not repeat is the personalization headers: device, country, bot. Those were computed at the edge on the first load and ride along unchanged. ## What stays true - One pageview, one session, one exposure, even without a page load. - The same token, so the same bins, so the same seats. - Consent still gates only sending. - The reveal still cannot be skipped. The page never reloaded, and the measurement never noticed. ## Where in the code File | What to look for | `sdk/sdk-js/src/init/modifyPage.ts` | `setupSPANavigation`, the 100 ms debounce, the 5,000-callback guard, `observeUrlChanges` handling with `AbortController` | `sdk/sdk-js/src/router.ts` | `observeUrlChanges`, the history patches, `urlsMatch` | `sdk/sdk-js/src/frameCache.ts` | `CACHE_TTL_MS`, `getCachedFrames`, `cacheFrames` | `sdk/sdk-js/src/metrics.ts` | `rebindMetrics` |