amitdusane.com Adobe Analytics Learning

Collect the dataAdobe Analytics Tracking Calls

SPA Tracking

This topic has surfaced twice already, once when the page view call ran into the fact that some sites never really load a page, and again when Launch rules needed something other than a browser event to fire on. It is time to meet it directly. The single page application is where the tidy idea of "a page loads, so count a page view" quietly falls apart, and understanding how to put it back together tells you a lot about how the whole tracking model actually works.

What a single page application really is

A single page application, or SPA, is not one technology. It is a way of building a site. The idea is simple: the site is really one page, and instead of loading a fresh page every time the visitor moves around, it just changes the parts of the screen that need to change. Technically there are no page loads after the first one, only screen changes.

Picture a web app for exam registration. It does not need many pages. When it opens, it shows the information that matters and asks for your details and documents. As you work through it, the interface updates in place, one step giving way to the next, and when everything is done you submit. From the visitor's point of view they moved through several screens. From the browser's point of view they never left the first one. One page served the whole journey, and that is exactly a SPA. The platforms used to build them, React, Angular, and their relatives, are the technology; the single page model is the methodology.

SPAs exist because they feel fast and app-like. There is no white flash of a reloading page, no waiting for the whole document to rebuild. That smoothness is the point, and it is also precisely what removes the signal analytics was relying on.

Why this breaks analytics, in two ways

The page view is one of the most important metrics you have, so losing the ability to count it cleanly is a real problem. A SPA breaks page view tracking in two separate ways, and both have to be solved.

The first is obvious once stated: there is no page load event to hang a rule on. The browser fires DOM Ready and Window Loaded once, at the very first load, and never again as the visitor moves through the app. The standard page load trigger simply goes quiet after the opening screen.

The second problem is less obvious and catches almost everyone. On a normal site, every page load builds a brand new Analytics object, the s object, so whatever variables you set on one page cannot possibly leak onto the next; the refresh wipes the slate. In a SPA there is no refresh, so the same s object stays alive for the entire visit and is reused for every virtual page view. That means variables persist when you do not want them to. A search term eVar set on the search results screen is still sitting there on the next screen. An events string you set once keeps firing on every subsequent beacon. Unless you deal with this deliberately, your data slowly contaminates itself as the visitor moves around.

Fixing it: telling Adobe a screen changed

Start with the trigger. One important clarification: the first load of a SPA is a genuine page load, and you track it exactly as you always would. It is only the screen changes after that first load which need a new mechanism. In practice a single Launch rule can fire on the normal page load event or on a virtual signal, so both the opening screen and every screen after it are covered.

There are two ways to raise that virtual signal, and they trade off against each other.

ApproachWho triggers itReliabilityBest for
Direct call (e.g. Virtual_Page_Load)The developer, from the app's router, at the moment a view is readyHigh. Explicit and under your control.The recommended default, especially with a data-layer-first setup.
History ChangeLaunch automatically, by listening for History API pushState and hash changesLower. Can miss real views or fire on URL changes that are not meaningful.A fallback when developer changes are not possible.

The direct call is the approach your implementation should reach for first. You give the developer an identifier, something like Virtual_Page_Load, and one clear instruction: whenever the visitor experiences what feels like a new screen, fire this call, and fire it after the data layer has been updated for that screen. A Launch rule listens for that identifier and sends the page view, exactly as the Send Beacon action would on a normal page. The History Change trigger is tempting because it needs no developer effort, but relying on it alone is one of the most common SPA mistakes: a route change is not always a page view, and the data layer may not have caught up when it fires, which produces duplicate and mistimed hits. The dependable pattern is to let the app's router be the authoritative announcer of a new view, and treat automatic history listeners only as a backup.

Timing is the quiet trap in all of this. The virtual page call must fire after the data layer holds the new screen's context, not before, or you will send the previous screen's data under the new screen's name. This is exactly why a data-layer-first approach helps so much in a SPA: the app updates the data layer as each view becomes ready, and the analytics call reacts to that, rather than guessing at the right moment. The groundwork laid in What Is a Data Layer is what makes SPA tracking manageable rather than fragile.

One more thing about that first screen, because it is where the double count comes from. Many frameworks call their router hook on the initial mount as well as on every navigation after it. So if a rule listens for the normal page load and the app also announces the opening view through the router, the entry screen sends two hits: one from the load, one from the announcement. Both are correct. Neither is wrong on its own. Together they count the visitor's first screen twice.

The most visited screen in the app is the one most likely to be double counted

Nothing errors, and the two hits carry the same page name, so the report looks plausible rather than broken. What it produces is an entry screen with roughly twice the page views of anything else, a bounce rate that collapses because almost nobody registers a single-page-view visit any more, and conversion rates quietly measured against an inflated denominator. Decide which mechanism owns the first view, the page load or the router, and make sure only one of them fires for it. The check takes seconds: load the app cold and count the hits before touching anything else.

Fixing the persistence problem

Now the second problem. Because the s object survives between virtual views, you have to clear it yourself. The rule is an ordering rule: set the variables for this view, send the beacon, then clear the variables so nothing carries into the next view. In Launch this is simply the Clear Variables action placed after Send Beacon, calling s.clearVars() under the hood. The sequence matters more than any single piece of it.

Clear the variables, or the next screen ships with the last screen’s data
The page never reloads, so the s object is never destroyed between screens With clearVars() after the beacon Screen 1 hit search, 3 filters clearVars() the s object is wiped Screen 2 hit booking only Without it Screen 1 hit search, 3 filters nothing clears it the values stay on s Screen 2 hit booking + search + filters Nothing errors in the bottom row. The booking screen simply reports search terms it never saw.
SPA virtual page view
// The developer fires this from the app's router, after the
// data layer has been updated for the new screen:
_satellite.track("Virtual_Page_Load");

// The Launch rule listening for "Virtual_Page_Load" then does,
// in this order: set variables, send the beacon, clear variables.

// The same idea in manual code, showing why order matters:
s.pageName = "Exam Registration: Upload Documents";
s.channel  = "Registration";
s.events   = "event3";
s.t();            // send the page view
s.clearVars();    // wipe the s object before the next screen

// Alternatively, register clearVars to run automatically
// after every successful hit:
s.registerPostTrackCallback(function () { s.clearVars(); });
Set, send, clear, every time

The single habit that keeps SPA data clean is treating every virtual page view as a full cycle: set this view's variables, send the beacon, then clear. Skip the clear and old values haunt the next screen. Clear too early and you wipe the values before they are sent. The order is the whole game.

A note on the Web SDK

If you are collecting with the Web SDK rather than AppMeasurement, the persistence problem looks different. Each sendEvent call is independent and carries its own data, so there is no long-lived s object quietly holding values between views. You still have to set the right data for each view, but you are building it fresh each time rather than clearing a shared object. The Web SDK also keeps the visitor's ECID in memory across route changes on its own, so identity stays intact as the visitor moves through the app without any re-fetching. This is one of the areas where the Web SDK smooths over old friction, and it is explored in depth in the AppMeasurement to Web SDK migration guide, a separate guide on this site.

Why mobile apps sidestep all of this

For a mobile app, the whole SPA question is beside the point. As covered in the Mobile SDK section, an app never had automatic page loads to begin with. The developer already decides when a screen has meaningfully changed and fires a trackState call for it. There is no browser page load to lose, so there is nothing to reconstruct. The developer simply chooses those moments wisely, and that takes care of it.

The real shift to carry out of this section is this: in a SPA, a "page view" is no longer something the browser hands you. It is something you have to define. You decide which screen changes are worth counting, you announce them deliberately, and you clean up after each one. And because every one of those virtual views is a full server call, the same discipline applies directly: count the screens that matter, not every flicker of the interface. Counting a view is only half the story. Each one belongs to a visitor, and how Adobe recognizes that visitor, across screens, across domains, and across return visits, rests on a single identifier: the Experience Cloud ID (ECID).

Where to find it in Adobe Analytics

Virtual page views appear in the same report as any page view: Workspace → Dimensions → Page

Check firing and order with the Adobe Experience Cloud debugger or browser network panel.

Need implementation steps?

This article focuses on the concepts, architecture, and practical guidance behind the topic. For the latest UI walkthroughs and step-by-step implementation instructions, use the links below. They leave this site and open Adobe's own documentation in a new tab.