Soft Navigations and Core Web Vitals: Optimizing SPA Performance
Soft Navigations often break standard Core Web Vitals tracking. Learn how to accurately measure and optimize Single-Page Application performance today.
We spent weeks chasing a ghost. Our dashboard showed "Good" scores for our main marketing landing page, but our users kept complaining about sluggish transitions within our app. It turns out, we were measuring the initial document load perfectly, but we were completely blind to what happened after the user clicked a link. We were dealing with the classic "SPA Performance Paradox."
In a traditional multi-page app, a navigation triggers a full browser refresh, resetting your Core Web Vitals metrics. In a Single-Page Application (SPA), the browser doesn't refresh; it just swaps out DOM nodes. This is where Soft Navigations come into play. If your tooling doesn't account for these, your metrics are lying to you.
The Reality of Soft Navigations in SPAs
When you build a client-side router, you're essentially faking a navigation. The browser sees one page load, but the user experiences five. Because standard performance APIs were designed for document-based navigations, they often ignore the "soft" transitions that define the user experience in modern apps.
If you're still relying solely on the standard window.performance API, you're missing the interaction latency that happens after the initial bundle executes. We previously explored how to master the Performance Observer API: Mastering Real User Monitoring Metrics to get better visibility, but observing the DOM isn't enough when the underlying data source—the navigation—isn't being reported correctly by the browser.
Bridging the Measurement Gap
To fix this, we first tried manually triggering custom events on every route change. It was a nightmare. We had to account for race conditions, asynchronous data fetching, and varying component render times. Our LCP (Largest Contentful Paint) calculations became bloated with custom logic that was hard to maintain.
The better way is to align your SPA’s internal routing state with the browser's native expectations. While the Navigation API is still evolving, it provides a much cleaner hook for intercepting transitions than the old popstate event hack.
Measuring Core Web Vitals in SPAs
When you track metrics in an SPA, you need to treat each "soft" route transition as a unique performance event. Here is how I set up a basic observer pattern to capture these:
JAVASCRIPTconst observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { // Check for soft navigation markers if (entry.entryType === CE9178">'navigation' && isSoftNav(entry)) { console.log(CE9178">'Soft Nav detected:', entry.name); reportVitals(entry); } }); }); observer.observe({ type: CE9178">'navigation', buffered: true });
This approach helps you capture the reality of your Single-Page Application Performance. However, measuring is only half the battle. If your interaction metrics are lagging, you might need to look into INP Optimization: Architecting Browser-Level Task Slicing to ensure that your UI thread isn't blocked during these transitions.
Strategy Comparison for SPA Routing
Choosing the right approach for handling transitions depends on your framework's capabilities and your tolerance for complexity.
| Strategy | Pros | Cons |
|---|---|---|
| Native Router | Minimal overhead | Hard to track metrics |
| Custom Observer | Full control over events | High maintenance |
| View Transitions API | Native, smooth UI | Limited browser support |
| Hybrid Approach | Best balance | Complex to implement |
If you're looking to make transitions feel truly instant, I highly recommend looking at the View Transitions API and Content-Visibility: Faster Page Navigation. It allows you to animate state changes without the typical layout thrashing that kills your Core Web Vitals scores.
Optimization Lessons Learned
We eventually reduced our perceived latency by about 150ms by deferring non-critical data fetching until after the route transition had visually committed. We initially tried pre-fetching everything before the route switch, but that actually caused a massive spike in our Interaction to Next Paint (INP) because the main thread was blocked by data processing.
Remember, Soft Navigations are not just about speed; they are about predictability. When a user clicks a link in your app, the browser should feel like it's responding to a high-priority event. If you're pre-fetching, ensure you're using modern standards like the Speculation Rules API: Architecting Instant Navigation Strategies to handle the heavy lifting without impacting your current page's resources.
I'm still not entirely convinced that we've found the "perfect" way to measure every edge case. We still see discrepancies between our synthetic lab data and real user telemetry from low-end Android devices. The next step for us is to implement more aggressive server-side rendering for the initial hit while keeping the client-side transitions for subsequent navigations. It’s a balancing act, and honestly, I suspect we'll be tweaking these thresholds for the next several quarters.