import csv, math, json, os, zipfile, textwrap
from datetime import date
from pathlib import Path
import matplotlib.pyplot as plt

out = Path('/mnt/data/spa_bargain_research_pack')
out.mkdir(exist_ok=True)

sources = {
    'russell_2025_article': 'https://calendar.perfplanet.com/2025/the-curious-case-of-the-shallow-session-spas/',
    'russell_2025_desktop_image': 'https://calendar.perfplanet.com/images/2025/alex/mpa-vs-spa-desktop.png',
    'russell_2025_mobile_image': 'https://calendar.perfplanet.com/images/2025/alex/mpa-vs-spa-mobile.png',
    'chrome_final_ot_2026': 'https://developer.chrome.com/blog/final-soft-navigations-origin-trial',
    'chrome_soft_nav_docs_2026': 'https://developer.chrome.com/docs/web-platform/soft-navigations',
    'wicg_soft_navs_2026': 'https://github.com/WICG/soft-navigations',
    'chromium_soft_nav_changelog_2026': 'https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/speed/metrics_changelog/soft_navigations.md',
    'rum_archive_readme': 'https://github.com/rum-archive/rum-archive',
    'rum_archive_methodology': 'https://rumarchive.com/docs/methodology/',
    'rum_archive_insights_2023': 'https://rumarchive.com/blog/2023-11-01-rum-archive-insights/',
    'hofman_2023_soft_nav_ttfb': 'https://calendar.perfplanet.com/2023/soft-navigations-spa-ttfb/',
    'debugbear_soft_navs_2026': 'https://www.debugbear.com/blog/soft-navigations',
    'debugbear_spa_monitoring_2025': 'https://www.debugbear.com/blog/single-page-application-monitoring',
    'debugbear_instant_navs_2025': 'https://www.debugbear.com/blog/instant-navigations',
    'webdev_vitals': 'https://web.dev/articles/vitals',
    'webdev_lcp': 'https://web.dev/articles/lcp',
    'webdev_ttfb': 'https://web.dev/articles/ttfb',
    'webdev_optimize_ttfb': 'https://web.dev/articles/optimize-ttfb',
    'webdev_bfcache': 'https://web.dev/articles/bfcache',
    'chrome_speculation_rules': 'https://developer.chrome.com/docs/web-platform/implementing-speculation-rules',
    'chrome_cross_document_view_transitions': 'https://developer.chrome.com/docs/web-platform/view-transitions/cross-document',
    'webdev_navigation_api_baseline_2026': 'https://web.dev/blog/baseline-navigation-api',
}

with open(out/'sources.json','w') as f:
    json.dump(sources, f, indent=2)

rum_rows = [
    {
        'device':'desktop',
        'mpa_percent':45.3,
        'spa_hard_percent':27.6,
        'spa_soft_percent':27.2,
        'soft_per_spa_hard_ratio':27.2/27.6,
        'event_weighted_spa_navigation_depth_proxy':1+27.2/27.6,
        'source':'Russell 2025 PerfPlanet image, derived from RUM Archive',
        'source_url':sources['russell_2025_desktop_image'],
        'note':'Ratio is SPA soft navigation beacons divided by SPA hard navigation beacons in published image. It is an event-weighted proxy, not a user-level session-depth histogram.'
    },
    {
        'device':'mobile',
        'mpa_percent':50.5,
        'spa_hard_percent':26.4,
        'spa_soft_percent':23.1,
        'soft_per_spa_hard_ratio':23.1/26.4,
        'event_weighted_spa_navigation_depth_proxy':1+23.1/26.4,
        'source':'Russell 2025 PerfPlanet image, derived from RUM Archive',
        'source_url':sources['russell_2025_mobile_image'],
        'note':'Ratio is SPA soft navigation beacons divided by SPA hard navigation beacons in published image. It is an event-weighted proxy, not a user-level session-depth histogram.'
    }
]
with open(out/'rum_source_observations.csv','w',newline='') as f:
    writer=csv.DictWriter(f, fieldnames=list(rum_rows[0].keys()))
    writer.writeheader(); writer.writerows(rum_rows)

# Source-derived break-even sensitivity grid
fieldnames=['device','soft_per_hard_ratio_r','initial_spa_penalty_ms_P','mean_soft_nav_saving_ms_delta','required_saving_to_break_even_ms_P_over_r','break_even_follow_on_count_kstar_P_over_delta','break_even_total_depth_dstar_1_plus_kstar','net_spa_minus_mpa_session_ms_at_observed_r','amortizes_at_observed_r','assumption']
rows=[]
for obs in rum_rows:
    r=obs['soft_per_spa_hard_ratio']
    for P in range(0,3001,100):
        for delta in range(0,3001,100):
            if delta == 0:
                kstar = math.inf if P>0 else 0
                dstar = math.inf if P>0 else 1
            else:
                kstar = P/delta
                dstar = 1+kstar
            req = P/r if r else math.inf
            net = P - r*delta
            rows.append({
                'device':obs['device'],
                'soft_per_hard_ratio_r':round(r,6),
                'initial_spa_penalty_ms_P':P,
                'mean_soft_nav_saving_ms_delta':delta,
                'required_saving_to_break_even_ms_P_over_r':round(req,3),
                'break_even_follow_on_count_kstar_P_over_delta':('inf' if math.isinf(kstar) else round(kstar,3)),
                'break_even_total_depth_dstar_1_plus_kstar':('inf' if math.isinf(dstar) else round(dstar,3)),
                'net_spa_minus_mpa_session_ms_at_observed_r':round(net,3),
                'amortizes_at_observed_r':net <= 0,
                'assumption':'SPA initial load is P ms slower than equivalent MPA initial load; each follow-on soft navigation saves delta ms versus an equivalent MPA hard navigation; r observed from source-derived RUM ratio.'
            })
with open(out/'amortization_grid.csv','w',newline='') as f:
    writer=csv.DictWriter(f, fieldnames=fieldnames)
    writer.writeheader(); writer.writerows(rows)

# Compact decision table
summary_rows=[]
for obs in rum_rows:
    r=obs['soft_per_spa_hard_ratio']
    for P in [250,500,1000,1500,2000,3000]:
        summary_rows.append({
            'device':obs['device'],
            'observed_r_soft_per_hard':round(r,3),
            'event_weighted_depth_proxy_1_plus_r':round(1+r,3),
            'initial_spa_penalty_ms':P,
            'soft_nav_saving_required_ms':round(P/r,1),
            'source_observation_url':obs['source_url']
        })
with open(out/'break_even_summary.csv','w',newline='') as f:
    writer=csv.DictWriter(f, fieldnames=list(summary_rows[0].keys()))
    writer.writeheader(); writer.writerows(summary_rows)

# Seed protocol registry template for 50+50 matched lab journeys (empty rows with required fields, not fabricated sites)
reg_fields = ['pair_id','vertical','task','spa_url','mpa_url','match_basis','route_step','route_from','route_to','requires_auth','ads_enabled','service_worker_state','bfcache_policy','speculation_rules_policy','primary_content_selector','lcp_expected_selector','notes','pre_registration_status']
reg_rows=[]
for pair_id in range(1,51):
    for step in range(1,6):
        reg_rows.append({
            'pair_id':f'P{pair_id:02d}',
            'vertical':'TBD: ecommerce/news/SaaS/content/travel/finance/productivity',
            'task':'TBD matched user journey',
            'spa_url':'TBD',
            'mpa_url':'TBD',
            'match_basis':'Same task, same content class, same geography/language, comparable auth and personalization, comparable cacheability',
            'route_step':step,
            'route_from':'TBD',
            'route_to':'TBD',
            'requires_auth':'TBD',
            'ads_enabled':'TBD',
            'service_worker_state':'cold/warm/disabled sensitivity',
            'bfcache_policy':'eligible/ineligible/measured',
            'speculation_rules_policy':'off in primary lab; on in secondary MPA-enhanced arm',
            'primary_content_selector':'TBD CSS selector used for route-ready validation',
            'lcp_expected_selector':'TBD selector or semantic label',
            'notes':'Do not fill after seeing performance results; freeze before measurement.',
            'pre_registration_status':'template'
        })
with open(out/'matched_journey_registry_template.csv','w',newline='') as f:
    writer=csv.DictWriter(f, fieldnames=reg_fields)
    writer.writeheader(); writer.writerows(reg_rows)

# plots
# Required saving line for initial penalty
Ps=list(range(0,3001,100))
for obs in rum_rows:
    r=obs['soft_per_spa_hard_ratio']
    plt.plot(Ps, [P/r for P in Ps], label=f"{obs['device']} r={r:.3f}")
plt.xlabel('Initial SPA first-load penalty P (ms)')
plt.ylabel('Mean soft-navigation saving required (ms)')
plt.title('Amortization requirement at observed RUM soft/hard ratios')
plt.legend()
plt.tight_layout()
plt.savefig(out/'required_saving_curve.png', dpi=200)
plt.close()

# Break-even total depth curves for fixed soft nav savings
for delta in [250,500,1000,1500,2000]:
    plt.plot(Ps, [1+(P/delta if delta else math.inf) for P in Ps], label=f'Δ={delta}ms')
plt.axhline(1+rum_rows[0]['soft_per_spa_hard_ratio'], linestyle='--', label='desktop observed proxy')
plt.axhline(1+rum_rows[1]['soft_per_spa_hard_ratio'], linestyle=':', label='mobile observed proxy')
plt.xlabel('Initial SPA first-load penalty P (ms)')
plt.ylabel('Break-even total navigation depth')
plt.title('How deep sessions must be for the SPA bargain to repay')
plt.legend()
plt.tight_layout()
plt.savefig(out/'break_even_depth_curve.png', dpi=200)
plt.close()

article = r'''# The SPA bargain under soft-navigation measurement: do soft navigations repay the slow first load?

**Article type:** research-grade synthesis, formal amortization model, and registered empirical benchmark design.  
**Prepared:** 2026-06-22.  
**Status:** source-derived results plus executable research protocol. This is not a fake lab-result paper; the included data are explicitly source-derived and model-derived until the 50 SPA + 50 matched MPA lab fleet is executed.

## Abstract

Single-page applications (SPAs) are usually justified by a bargain: accept a heavier first load so that subsequent route changes are faster than equivalent multi-page application (MPA) navigations. Until recently, the bargain could not be tested cleanly at web scale because Core Web Vitals and most Performance Timeline primitives were aligned to cross-document navigations, while SPA route changes are same-document navigations. Chrome's final Soft Navigations origin trial in Chrome 147-149 changes the measurement frontier by exposing `soft-navigation`, `interaction-contentful-paint`, and `navigationId` attribution, enabling LCP/FCP/CLS/INP-style slicing for SPA route changes.

This paper makes three contributions. First, it states the SPA bargain as a falsifiable amortization equation: a SPA repays its first-load penalty only when the cumulative savings from follow-on soft navigations exceed the initial hard-load penalty. Second, it applies the equation to a source-derived RUM Archive observation published by Russell (2025): desktop SPA soft-navigation beacons are 27.2% of observed navigation-like events versus 27.6% SPA hard beacons, and mobile soft beacons are 23.1% versus 26.4% hard beacons. The resulting soft-per-hard ratios are 0.986 desktop and 0.875 mobile. These are not user-level session histograms, but they are a strong event-weighted warning: at these depths, a 1,000 ms initial SPA penalty requires roughly 1,015 ms of average soft-navigation saving on desktop and 1,143 ms on mobile merely to break even. A 2,000 ms penalty requires roughly 2,029 ms and 2,286 ms respectively. Third, it specifies a benchmark protocol capable of answering the remaining empirical question—whether equivalent SPA soft navigations are actually faster than MPA navigations—without uncontrolled architectural, network, cache, or measurement bias.

The paper's defensible conclusion is deliberately narrower than common SPA advocacy. SPAs may repay in deep, authenticated, stateful, or locally optimistic applications such as editors, dashboards, maps, chat, and complex transactional tools. The general-purpose assumption that SPA route changes compensate for slow first loads is not justified for shallow public sessions unless a team can demonstrate, on its own traffic mix, that \(P \leq K\Delta\), where \(P\) is the SPA first-load penalty, \(K\) is the realized follow-on navigation count, and \(\Delta\) is the measured per-navigation saving versus the best MPA baseline, including bfcache, HTTP caching, CDN HTML caching, speculation rules, and cross-document view transitions where applicable.

## 1. Research question and claim boundary

The research question is: **Are SPA soft navigations actually faster than equivalent MPA navigations, and do they amortize within real session depths?**

The answer has two separable parts.

1. **Speed comparison:** whether a soft navigation is faster than an equivalent hard navigation for the same user task, cache state, device class, network, route complexity, and content dependency graph.
2. **Amortization:** whether the observed number of follow-on navigations in real sessions is large enough for any speed advantage to repay the slower first load.

A methodologically serious paper must not conflate these. A soft navigation can be faster and still fail the bargain if sessions are shallow. A SPA can repay for authenticated power users and fail for anonymous landing traffic. A naive comparison of arbitrary React sites with arbitrary static sites would be invalid; the estimand is the counterfactual latency of equivalent user-visible transitions under two architectures, not the average speed of websites that happened to choose React or server rendering.

The paper therefore treats the existing public evidence as a **prior and constraint**, not as final experimental proof. It closes the amortization part as a formal decision rule and provides the measurement design required to close the speed part with publishable data.

## 2. Why the gap is real

The measurement gap is structural. A hard navigation creates a new document and resets browser-native page-load metrics. A soft navigation is user-visible navigation-like work performed inside an existing document: JavaScript intercepts a click, updates history or URL state, fetches or computes data, mutates the DOM, and paints new content. From the user's perspective, this can be a page transition. From the classic browser metrics perspective, it historically remained part of the original page lifetime.

The WICG soft-navigation explainer frames the problem directly: existing Web Performance Timeline APIs are rich for cross-document navigations but lack a mechanism to measure dynamic same-document user experiences; paint timings are tied to the initial hard page load and later same-document work is usually attributed to the original URL. The proposed solution adds `InteractionContentfulPaint` and `PerformanceSoftNavigation`, plus `navigationId` attribution to slice timeline entries by navigation-like same-document transitions.

Chrome's 2026 final origin-trial post states the same operational need: without an actual page navigation, actions such as Core Web Vitals measurement are not possible in the same way for SPA route changes; the final trial introduces entries and identifiers intended to measure LCP, FCP, CLS, and INP for soft navigations. It also fixes the definition of a detected soft navigation around three user-centered signals: a user interaction, a visible paint, and a URL update.

DebugBear's 2026-updated material likewise treats soft-navigation measurement as an unresolved monitoring problem: SPAs rely on soft navigations that are less obvious to detect than hard navigations, and monitoring tools historically focus on the initial page load. Its later SPA monitoring article lists the practical questions that follow: whether an SPA is one page view or many, when a new page view happens, whether metrics should be lifetime-wide or navigation-specific, and which URL should receive attribution.

This is enough to reject a common but weak claim: “SPAs are faster after the first load.” Before the final soft-navigation API shape, that claim usually lacked browser-native per-navigation paint evidence. After Chrome 147-149, it becomes testable, but not automatically true.

## 3. Existing evidence

### 3.1 RUM Archive and shallow sessions

RUM Archive is a public, queryable collection of anonymized, aggregated RUM data. Its documentation states that providers publish aggregated page-load and resource data to public BigQuery tables; rows are tuples of dimensions with counts and histograms that can be combined to calculate approximate percentiles. The same documentation also notes privacy-preserving aggregation and a minimum count threshold in the mPulse data, which means extreme low-count tuples are discarded and outliers can affect percentile estimates.

Akamai mPulse-backed RUM Archive data distinguishes MPA loads, SPA hard navigations, and SPA soft navigations. RUM Archive's 2023 insight article reported that MPAs were still the majority of the sampled experiences, while SPAs represented a substantial minority; more importantly for this paper, it confirms the taxonomy used in later shallow-session analysis.

Russell (2025) published the key source-derived observation: multiple independent data sets indicated roughly one SPA soft navigation per SPA hard load, and he called the resulting question one of the most important web-performance mysteries for 2026. The published RUM Archive charts show the following shares of navigation-like events:

| Device | MPA | SPA hard | SPA soft | Soft / SPA-hard ratio | Event-weighted SPA depth proxy |
|---|---:|---:|---:|---:|---:|
| Desktop | 45.3% | 27.6% | 27.2% | 0.986 | 1.986 |
| Mobile | 50.5% | 26.4% | 23.1% | 0.875 | 1.875 |

The ratio is not a user-level session-depth distribution. It is an event-weighted ratio derived from published aggregate beacons. It can be biased by instrumentation, bot filtering, page visibility, SPA definitions, and aggregation. Still, it has high decision value because even a rough ratio near one creates a severe amortization constraint.

### 3.2 Soft-navigation TTFB and the “network is gone” myth

Hofman (2023) reports RUMvision's attempt to measure TTFB for SPA soft navigations. The article is useful because it shows both promise and messiness. RUMvision often saw much smaller TTFB values for soft navigations, but the author explains why general-purpose TTFB attribution is hard: a soft navigation may not issue a fetch; multiple candidate fetches may occur; data may be prefetched on click, hover, or earlier; and frameworks such as Next.js, Nuxt, Angular, Gatsby, and custom PWAs expose different resource patterns. RUMvision therefore added framework- and template-specific endpoint matching.

The implication is important: even when soft navigations are faster, their advantage may come from prefetching or already-loaded data, not from the SPA architecture alone. Equivalent MPAs can also prefetch or prerender likely documents, use bfcache for back/forward transitions, cache HTML at the edge, and use cross-document view transitions for perceived continuity.

### 3.3 MPA counterfactuals are improving

The fair comparison is not SPA versus an unoptimized 2012 MPA. Modern MPAs can use bfcache, speculation rules, CDN-cached HTML, view transitions, HTTP cache reuse, and the Navigation API where appropriate. DebugBear describes instant navigation techniques including fast content delivery, speculation rules, bfcache, and SPAs, and explicitly notes that some techniques work only after the first page view. Chrome's speculation-rules documentation states that prefetching or prerendering future page navigations can provide quicker or even instant navigations. web.dev describes bfcache as a browser optimization that enables instant back and forward navigation. Chrome's cross-document view-transition documentation states that cross-document view transitions are supported for MPAs, reducing one of the historical “SPA-feels-smoother” arguments.

Therefore, the relevant counterfactual is **best practical MPA**, not **unoptimized hard reload**.

## 4. Formal model: the amortization frontier

Let a session begin with one initial navigation. Define:

- \(M_0\): initial MPA hard-navigation latency for the landing page, measured by a chosen user-centric metric such as route-ready LCP or primary-content paint.
- \(S_0\): initial SPA hard-navigation latency for the equivalent landing page.
- \(P = S_0 - M_0\): initial SPA first-load penalty. If \(P < 0\), the SPA is already faster on first load and the bargain is trivially easier; in the pagespeed debate, the usual concern is \(P > 0\).
- \(H_i\): latency of the \(i\)-th equivalent MPA follow-on navigation.
- \(C_i\): latency of the \(i\)-th SPA soft navigation.
- \(\Delta_i = H_i - C_i\): soft-navigation saving. Positive values favour the SPA.
- \(K\): number of follow-on navigations in the session.

The SPA repays the first-load penalty when:

\[
P \leq \sum_{i=1}^{K}\Delta_i.
\]

If average follow-on saving is \(\bar{\Delta}\), the break-even follow-on count is:

\[
K^* = \frac{P}{\bar{\Delta}}.
\]

Total break-even navigation depth is:

\[
D^* = 1 + \frac{P}{\bar{\Delta}}.
\]

If the observed follow-on count proxy is \(r\), amortization at that proxy requires:

\[
P \leq r\bar{\Delta}, \quad \text{or equivalently} \quad \bar{\Delta} \geq \frac{P}{r}.
\]

This is a necessary-and-sufficient accounting identity for a given latency metric and session definition. It is not a regression model and it does not depend on p-values. Methodological disputes should therefore focus on measurement of \(P\), \(\Delta\), and \(K\), not on the logic of the equation.

## 5. Source-derived results: what shallow sessions imply

Using Russell's published RUM Archive ratios:

- Desktop: \(r = 27.2/27.6 = 0.986\).
- Mobile: \(r = 23.1/26.4 = 0.875\).

The average soft-navigation saving needed to amortize a first-load SPA penalty is therefore:

| Initial SPA penalty \(P\) | Required saving, desktop \(P/0.986\) | Required saving, mobile \(P/0.875\) |
|---:|---:|---:|
| 250 ms | 254 ms | 286 ms |
| 500 ms | 507 ms | 571 ms |
| 1,000 ms | 1,015 ms | 1,143 ms |
| 1,500 ms | 1,522 ms | 1,714 ms |
| 2,000 ms | 2,029 ms | 2,286 ms |
| 3,000 ms | 3,044 ms | 3,429 ms |

This table is the core decision result. If a SPA costs 1 second on first load and average real sessions contain roughly one follow-on route transition, the route transition must save about a full second merely to break even. If the first-load penalty is 2 seconds, the follow-on must save about 2.0-2.3 seconds. If the MPA alternative uses bfcache or prerender on a subset of navigations, \(H_i\) shrinks and the required SPA advantage becomes harder to achieve.

The result does not prove that SPAs are slow. It proves that shallow sessions make the bargain fragile. Architecture selection should therefore become an amortization-budget decision rather than a framework fashion decision.

## 6. Why naive empirical studies would be flawed

A Harvard-level critique would correctly reject any study with the following defects:

1. **Arbitrary-site bias:** comparing random SPA sites with random MPA sites confounds architecture with industry, budget, engineering quality, CDN setup, ads, analytics, personalization, image weight, and backend latency.
2. **Metric mismatch:** comparing SPA soft-navigation paint to MPA full-load LCP without matching cache state and route dependency measures different user experiences.
3. **Ignoring first-load penalty:** reporting only follow-on route speed hides the cost that users pay to enter the application.
4. **Ignoring session depth:** proving a soft navigation is faster says nothing about whether users experience enough of them.
5. **Unfair MPA baseline:** excluding bfcache, speculation rules, document prefetch, CDN HTML caching, and cross-document view transitions overstates the SPA advantage.
6. **Soft-navigation detection error:** relying on framework events alone creates incomparable definitions; relying on browser heuristics alone can miss non-URL view updates or count edge cases the product team would not call navigation.
7. **Cache and prefetch contamination:** a SPA route that was prefetched on hover should be compared with an MPA document that had equivalent speculation opportunity, not with a cold hard navigation.
8. **Aggregating means:** p75 and tail latency matter more for Core Web Vitals and accessibility than mean latency.
9. **Browser-version instability:** Chrome 147-148 changes and bug fixes around `navigationId` and `InteractionContentfulPaint` must be version-controlled.

The benchmark below is designed to remove these objections.

## 7. Registered benchmark design: B-SNA, the Bargain Soft Navigation Audit

### 7.1 Estimand

The primary estimand is the p75 difference in route-ready latency between equivalent SPA soft navigations and MPA hard navigations for the same user task, under matched device, network, geography, cache state, prefetch policy, route, and content class:

\[
\tau_{0.75} = Q_{0.75}(C_i) - Q_{0.75}(H_i).
\]

Negative values favour SPA follow-on speed. The amortization estimand is:

\[
A = P - \sum_{i=1}^{K}\Delta_i,
\]

with \(A \leq 0\) indicating repayment.

### 7.2 Study arms

The benchmark has two complementary arms.

**Arm A: Natural matched web journeys.** Select 50 SPA journeys and 50 matched MPA journeys from public production websites. Matching is by vertical, task, geography/language, content type, authentication status, ad exposure, and cacheability. This arm has external validity but residual confounding.

**Arm B: Controlled paired implementations.** Rebuild a subset of journeys as controlled SPA and MPA variants over the same content API and design system. This arm isolates architecture but has lower external validity. The combined design prevents both the “not real sites” and “confounded real sites” critiques.

### 7.3 Journey structure

Each pair contains at least five navigation-like steps, for example:

1. landing/search page to listing;
2. listing to detail;
3. detail to related detail;
4. detail to cart or saved item;
5. back/forward or return-to-list navigation.

Each step must have a primary-content selector frozen before measurement. A route is not considered complete merely because the URL changed; it is complete when the expected primary content is painted and visually stable enough to be usable.

### 7.4 Measurement environment

Run Chrome with the final soft-navigation API enabled or via origin trial as required by the study date. Pin exact browser versions and repeat a sensitivity slice across the next stable version when the soft-navigation changelog reports attribution or heuristic changes.

Use four profiles:

- high-end desktop, fast broadband;
- midrange Android, 4G;
- low-end Android, constrained CPU and variable RTT;
- mobile with packet loss and high latency.

For each pair, route, architecture, and profile, run at least 31 repetitions after warm-up. Randomize route order and architecture order within blocks. Clear storage for cold-first-load tests; preserve HTTP cache for warm-follow-on tests; run separate service-worker-enabled and service-worker-disabled sensitivity profiles when service workers are part of the production architecture.

### 7.5 Instrumentation

For MPA hard navigations, collect Navigation Timing, Resource Timing, LCP, FCP, CLS, INP, bfcache status, prerender activation status, and trace/filmstrip evidence.

For SPA soft navigations, collect:

- `soft-navigation` entries;
- `interaction-contentful-paint` entries;
- `largestInteractionContentfulPaint` from the soft-navigation entry;
- `navigationId`-sliced layout shifts and event timings;
- Resource Timing entries associated with the transition;
- product-defined route-ready selector time;
- spinner/skeleton dwell time;
- JavaScript long tasks and long animation frames where available;
- trace and filmstrip evidence.

Do not use a single metric as truth. The primary metric is route-ready primary-content paint; Core Web Vitals-aligned soft LCP/ICP is the main standards-aligned secondary metric.

### 7.6 Prefetch, prerender, and bfcache policy

The primary comparison disables explicit prefetch/prerender in both architectures unless it is inseparable from the architecture. The secondary comparison enables each architecture's best practical optimization:

- SPA with route/data prefetch as implemented by the framework;
- MPA with speculation rules, HTTP prefetch, bfcache eligibility, CDN HTML caching, and cross-document view transitions where appropriate.

This dual arm answers two different questions: “What does the architecture do by itself?” and “What should a competent team choose in production?”

### 7.7 Statistical analysis

Use cluster bootstrap by site-pair and route to estimate confidence intervals for p50, p75, and p95 deltas. Report paired deltas, not just marginal distributions. Use hierarchical quantile or Bayesian distributional models with random intercepts for pair and route, and fixed effects for architecture, device profile, vertical, cache state, and optimization arm.

Primary decision rule:

1. Estimate \(P_{0.75}\), the p75 SPA first-load penalty.
2. Estimate \(\bar{\Delta}_{0.75}\), the p75 follow-on saving versus equivalent MPA navigation.
3. Combine with field-derived \(K\) distribution by segment, not merely the mean.
4. Accept the SPA bargain only where \(P_{0.75} \leq E[K \mid segment]\bar{\Delta}_{0.75}\), with sensitivity for p95 tail users.

Multiple comparisons are handled by hierarchical shrinkage and predeclared subgroup reporting, not by cherry-picking winning frameworks.

### 7.8 Data quality and exclusion rules

Exclude a run only under predeclared conditions: network failure, HTTP error, visual non-equivalence, failed route-ready selector, bot/consent interstitial, or detected experiment variant mismatch. Never exclude a run because it is slow. Soft-navigation false positives and false negatives must be manually audited on a stratified 10% sample using traces and filmstrips.

Non-URL view updates—tabs, accordions, infinite scroll, client-side filters—are measured in a separate “view update” stratum. They are not silently mixed into soft-navigation results because Chrome's heuristic intentionally requires user interaction, visible paint, and URL update for canonical soft-navigation detection.

## 8. Decision framework for teams

A team can use the following architecture gate before choosing an SPA-by-default stack.

1. Measure the p75 first-load penalty \(P\) against the best MPA baseline.
2. Measure the p75 average saving \(\Delta\) across actual follow-on transitions, not demo routes.
3. Estimate the session-depth distribution \(K\) for target segments.
4. Compute \(P - K\Delta\) for anonymous, returning, authenticated, mobile, and low-end-device users separately.
5. Include the MPA-enhanced counterfactual: bfcache, speculation rules, edge-cached HTML, and cross-document view transitions.
6. Choose SPA only for segments where the bargain repays with margin, or where non-latency requirements dominate: offline editing, local optimistic state, complex collaborative UI, GIS/maps, continuous media, or application shells with high repeat depth.

The gate is intentionally simple. Its value is that it forces evidence. A team that cannot estimate \(P\), \(\Delta\), and \(K\) is not making a performance-based architecture decision.

## 9. Interpretation

The best current interpretation is contrarian but not anti-SPA. The SPA bargain is real for some applications and probably false for many public content, marketing, catalog, and shallow e-commerce sessions. When RUM-derived soft/hard ratios are near one, the first load is not amortized across many follow-on transitions. In that world, every extra kilobyte and millisecond in the initial JavaScript path has to be paid back almost immediately.

This is especially damaging on mobile. The source-derived mobile ratio is lower than desktop, and mobile is precisely where CPU, memory, RTT, and JavaScript parse/compile costs are more painful. A mobile SPA that is 1-2 seconds slower on first load cannot be justified by vague claims of “instant later navigation.” It needs measured savings of approximately the same magnitude within the first follow-on navigation, or it needs a segment with demonstrably deeper sessions.

The analysis also changes how performance teams should talk to product and engineering leaders. The question is not “React or no React?” It is: **What is the measured payback period of client-side routing for this user segment?**

## 10. Limitations

The source-derived RUM ratios used here come from published aggregate charts, not from a fresh BigQuery query executed for this paper. They are therefore suitable for a decision prior and sensitivity model, not for final empirical claims about all sites. RUM Archive aggregation protects privacy but also means low-count tuples and outliers may be suppressed. The ratios are event-weighted and do not reveal the full user-level session-depth distribution.

The Soft Navigations API was still in origin-trial/finalization status in the cited Chrome materials, and implementation details changed across versions. A final empirical benchmark must pin browser versions and re-run sensitivity checks when the API ships by default or changes semantics.

Finally, amortization depends on the chosen latency metric. LCP-like metrics, route-ready primary-content paint, spinner dwell time, INP, and CLS can disagree. A serious benchmark should report all of them and avoid optimizing for a number that does not match user-perceived readiness.

## 11. Conclusion

The SPA bargain can now be stated precisely:

\[
\text{SPA justified by speed} \iff P \leq \sum_{i=1}^{K}\Delta_i.
\]

The existing public evidence makes the burden of proof high. RUM Archive-derived ratios published by Russell indicate roughly one SPA soft navigation per SPA hard load, which means the first-load penalty must be recovered almost immediately. For a 1 second first-load penalty, the average follow-on transition must save roughly 1.0-1.1 seconds. For a 2 second penalty, it must save roughly 2.0-2.3 seconds. Modern MPAs can further reduce the available saving through bfcache, speculation rules, edge caching, and cross-document view transitions.

Therefore, the default architecture recommendation should change: **do not choose an SPA for page-speed reasons unless the team has measured its amortization frontier for the actual session-depth distribution.** Choose it for deep, stateful, offline, optimistic, or highly interactive product reasons; otherwise start with an MPA or hybrid architecture and add client-side islands only where measured user tasks need them.

## References

Chrome for Developers (2026a) *Final Soft Navigations origin trial starting in Chrome 147*. Available at: https://developer.chrome.com/blog/final-soft-navigations-origin-trial

Chrome for Developers (2026b) *Measuring soft navigations*. Available at: https://developer.chrome.com/docs/web-platform/soft-navigations

Chromium (2026) *Soft Navigation Heuristics Changelog*. Available at: https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/speed/metrics_changelog/soft_navigations.md

DebugBear / Monus, A. (2026) *A Guide To Soft Navigations And Core Web Vitals Reporting*. Available at: https://www.debugbear.com/blog/soft-navigations

DebugBear / Zeunert, M. (2025a) *How To Monitor Single Page Application Performance*. Available at: https://www.debugbear.com/blog/single-page-application-monitoring

DebugBear / Zeunert, M. (2025b) *How To Achieve Instant Navigations On The Web*. Available at: https://www.debugbear.com/blog/instant-navigations

Hofman, E. (2023) *Beyond soft-navigations: tracking your SPA’s TTFB*. Web Performance Calendar. Available at: https://calendar.perfplanet.com/2023/soft-navigations-spa-ttfb/

RUM Archive (2026a) *RUM Archive GitHub README*. Available at: https://github.com/rum-archive/rum-archive

RUM Archive (2026b) *Methodology*. Available at: https://rumarchive.com/docs/methodology/

RUM Archive (2023) *RUM Insights*. Available at: https://rumarchive.com/blog/2023-11-01-rum-archive-insights/

Russell, A. (2025) *The Curious Case of the Shallow Session SPAs*. Web Performance Calendar. Available at: https://calendar.perfplanet.com/2025/the-curious-case-of-the-shallow-session-spas/

WICG (2026) *Soft Navigations and Interaction Contentful Paint*. Available at: https://github.com/WICG/soft-navigations

web.dev (2026) *Web Vitals*. Available at: https://web.dev/articles/vitals

web.dev (2025a) *Back/forward cache*. Available at: https://web.dev/articles/bfcache

Chrome for Developers (2025) *Guide to implementing speculation rules for more complex sites*. Available at: https://developer.chrome.com/docs/web-platform/implementing-speculation-rules

Chrome for Developers (2024) *Cross-document view transitions for multi-page applications*. Available at: https://developer.chrome.com/docs/web-platform/view-transitions/cross-document
'''
with open(out/'article_spa_bargain.md','w') as f:
    f.write(article)

# README
readme = '''# SPA bargain research pack

This pack contains a research-grade article draft, source-derived data, model-derived amortization grids, and a benchmark registry template.

Files:
- `article_spa_bargain.md`: full manuscript with Harvard-style references.
- `rum_source_observations.csv`: exact source-derived RUM ratios from Russell's published RUM Archive charts.
- `break_even_summary.csv`: compact break-even table for common first-load penalties.
- `amortization_grid.csv`: full sensitivity grid for initial SPA penalty and soft-navigation saving assumptions.
- `matched_journey_registry_template.csv`: 50-pair / 5-step pre-registration template for the proposed lab fleet. It is intentionally blank for URLs; filling it after seeing measurements would create selection bias.
- `required_saving_curve.png`: required soft-navigation saving at observed RUM ratios.
- `break_even_depth_curve.png`: total session depth required to amortize for common saving assumptions.
- `sources.json`: source URLs used in the pack.

Data provenance:
The data are source-derived and model-derived, not fabricated lab measurements. The RUM ratios are calculated from the published chart percentages in Russell (2025): desktop 27.2/27.6 and mobile 23.1/26.4. The amortization grids are deterministic transformations of the equation P <= r*delta.
'''
with open(out/'README.md','w') as f: f.write(readme)

# Write a reproducibility script, as text, can regenerate datasets/figures.
script_path=out/'analysis_reproduce.py'
with open(script_path,'w') as f:
    f.write(Path('/tmp/build_spa_pack.py').read_text())

zip_path=Path('/mnt/data/spa_bargain_research_pack.zip')
if zip_path.exists(): zip_path.unlink()
with zipfile.ZipFile(zip_path,'w',zipfile.ZIP_DEFLATED) as z:
    for p in sorted(out.iterdir()):
        z.write(p, arcname=f'spa_bargain_research_pack/{p.name}')
print(zip_path)
