The On/Off SPA: Client-Side Navigation as a Removable Layer
Single-page applications made one promise that still holds up: navigation without throwing away the page. Click a link and, instead of tearing down the document and rebuilding it, the app swaps in the next view while everything else stays put. For a site built around one expensive, stateful widget, that is worth a lot.
But committing to a single-page app is a large bet. You trade a simple, cacheable, server-rendered site for a client runtime that owns routing, data loading, error handling, and analytics. When it breaks, it tends to break the whole site at once, and rolling back is rarely a one-line change.
We wanted the navigation feel without the bet. This post describes an architecture where the site is a plain server-rendered multi-page app by default, and single-page navigation is an enhancement layer we can switch on or off per visitor, with no change to what the server sends.
The baseline is a boring multi-page site
Every route renders a complete document on the server. No client framework is required to see content. Pages are cacheable at the edge because the response does not depend on who is asking for it. This is the fallback we never want to lose: if the enhancement layer is disabled, misbehaving, or simply never loads, the site still works exactly as a server-rendered site does.
On top of that baseline we add a thin client layer. It intercepts clicks on in-scope links, fetches a fragment of the destination page, and swaps only the region that changed. The shell around that region, including the expensive widget we did not want to re-initialize, is never torn down.
One switch, split in two
The most important design decision is that the feature can be turned off completely, and that "off" is the default. The switch has two halves.
The first half is coarse eligibility, decided on the server. A configuration flag says whether a given part of the site is even a candidate for client navigation. Because this does not depend on the individual visitor, the server's output stays cacheable.
The second half is the per-visitor decision, and we deliberately make it on the client. A stable session identifier is hashed to assign each visitor to a control group (ordinary full page loads) or a treatment group (client navigation). Doing this in the browser keeps the server response identical for everyone, which is what lets the edge cache keep working. If we varied the HTML per visitor on the server, we would lose the cache and the whole point of the baseline.
Two consequences fall out of this. First, the feature is really an experiment rather than a leap of faith: we can compare the two groups and measure whether the enhancement actually helps. Second, the eligibility gate must be explicit. It is tempting to let a general "experimental features" flag also enable client navigation, but any implicit inheritance quietly breaks the promise that the feature ships off by default. The gate should check exactly one thing.
What one navigation actually does
When an eligible visitor clicks an in-scope link, the client layer takes over. It updates the address bar, fetches the fragment for the destination, and swaps in the new content. A single navigation lifecycle owner is responsible for the cross-cutting work that a full page load used to do for free: recording history, sending an analytics event, refreshing anything that is billed or measured per page, capturing performance metrics, and recovering from failures. The shell, including the persistent widget, stays booted the entire time.
That persistence is the entire benefit. It is also the source of the two most interesting bugs.
Trap one: events that only fire once
Persistent widgets often announce that they are ready with an event that fires exactly one time, on first boot. Any feature that waits for that event to lazily initialize itself will work on a cold page load and silently fail to appear after a client navigation, because the event fired before the feature's listener existed.
This is easy to miss, because the obvious test (load the page directly) passes. The failure only shows up on the second navigation, which is the normal path in a single-page flow. The fix is to give every "run when ready" hook an already-ready fast path: run immediately if the widget is already up, or on the event if it is not. Centralize that logic in one place so that the next feature someone adds does not reintroduce the same bug.
Trap two: fragments are coupled to the bundle
The harder problem is caching. The fragment responses are cacheable, which is good for speed, but a fragment is tightly coupled to the client-side code that hydrates it. It carries serialized component state and references to code chunks that only make sense for the exact build that produced it.
Now deploy a new version. A visitor still running the old build clicks a link and, because fragments are cacheable, receives a fragment produced by the new build. The old client tries to hydrate markup it does not understand, dereferences something that is not there, and the navigation freezes: the address bar has already changed, but the old view is still on screen.
We fixed this in stages, and the stages are instructive. The first fix was client-side recovery: detect that a fragment failed to apply and convert the freeze into a single clean reload, which lands on the destination with a matching build. This is necessary, but it relies on timing signals that a fast cache hit can beat, so it is not enough on its own.
The durable fix is to make the cache aware of the build. Every fragment request is stamped with the identifier of the build that is asking. That stamp becomes part of the cache key, so a client on one build can only ever receive a fragment from that same build, or a miss that goes to the origin. When the origin sees a request stamped with a build it no longer serves, it responds with a small directive telling the client to do a full reload. The result is deterministic and self-healing: mismatches turn into one clean reload instead of a crash, and the problem ages out on its own.
There was one memorable trap inside the fix. Our first version of the reload directive was an empty response with an unusual status code and no body. It hung. The content delivery network kept the connection open waiting for a body that was never framed, and requests timed out instead of reloading. The lesson was to send a perfectly ordinary empty 200 response and carry the directive in a header, rather than relying on an unusual status with no body. Obvious in hindsight, invisible until it is live.
Measuring both groups honestly
An experiment is only as good as its measurement, and client navigation makes measurement subtle. A few rules earned through mistakes.
Count the control group the same way as the treatment group. If you suppress client navigation for the control group by intercepting clicks, be careful not to also disable the shared click handler that analytics depends on. One over-broad interception and the control group quietly under-reports, which poisons the comparison.
Re-synchronize state that lives outside the swapped region. It is common to keep a "current view" marker on the page root, outside the fragment that gets replaced. After a swap, that marker is stale, and anything that reads it (feature gating, error reporting) is now acting on the previous page. Update it as part of the navigation lifecycle.
Do per-navigation what a full load used to do per page: send the pageview, refresh the billed or measured components, sample performance. Otherwise the two groups diverge for reasons that have nothing to do with the feature you are testing.
A security footgun worth naming
The signal that marks a request as a fragment request is often just a query parameter, which means anyone can add it to any URL. If that parameter changes server behavior (skipping stylesheet delivery, skipping localization, returning a bare fragment), it must be gated behind real eligibility. Otherwise a stray or malicious parameter can make the server return a broken, unstyled page to ordinary visitors.
What we would keep, and what we would design first
The layer earns its keep. Expensive widgets survive navigation, transitions feel instant, and the whole thing has an off switch: disable it and you are back to a plain server-rendered site with no drama. Because it is an experiment, we can justify it with numbers instead of taste.
The cost is real too. The coupling between fragments and the client bundle is a permanent tax on your caching and deploy story. If we started again, we would design the build-versioned cache key on day one instead of discovering the need for it in production, treat fire-once lifecycle events as a known hazard from the start, and write the analytics-parity checks before shipping either group.
Progressive enhancement did not go away. It moved up a layer. You can have the single-page feel without surrendering the operational calm of server-rendered pages, as long as the single-page part is something you can switch off.