SPA vs MPA Tradeoffs
Deciding whether navigation should be a full document reload or a client-side DOM swap is the single architectural choice that constrains every routing decision downstream: your performance budget, your crawlability, your accessibility story, and how much state-synchronisation code you are signing up to maintain. A Multi-Page Application (MPA) asks the server for fresh HTML on every link click; a Single-Page Application (SPA) loads one shell, intercepts clicks, and rewrites the page in place. Neither is universally correct. This page walks through the concrete tradeoffs, gives you the primitives to build a correct client-side navigation layer, and shows how to measure the difference so the choice is evidence-led rather than ideological.
← Back to Routing Architecture & Fundamentals
The Problem
The failure mode is rarely “SPA is slow” or “MPA is clunky” in the abstract. It is that teams adopt SPA navigation for the perceived snappiness and then inherit a cascade of obligations the browser used to handle for free. When the server stops sending a new document per navigation, the browser stops doing four things automatically: it stops resetting scroll position, it stops moving keyboard focus to the top of the new page, it stops updating the document title and meta tags for crawlers, and it stops returning a real HTTP status for unmatched paths.
Each of those gaps becomes a defect. Scroll position bleeds between views. Screen-reader users are stranded mid-page after a navigation with no announcement. Search engines index a stale title because the meta update raced the hydration pass. A mistyped URL returns 200 OK with an empty shell — a soft 404 — quietly draining crawl budget. On the MPA side, the symmetric problem is latency and duplicated work: every navigation re-downloads, re-parses, and re-executes the framework runtime, throwing away warm application state and forcing a fresh paint even when 90% of the layout is identical.
The correct architecture is the one whose default failure mode you can afford. Choosing it well means understanding exactly which guarantees you forfeit and what it costs to rebuild them.
Core API & Primitives
Client-side navigation is built on a small set of browser primitives. Getting their signatures right is most of the battle, because the subtle bugs come from misusing the state argument or the scroll-restoration flag.
// TypeScript 5.x — framework-agnostic; lib.dom signatures
// The four primitives any SPA navigation layer leans on.
// 1. Push a new history entry without a network request.
// The first argument is structured-cloneable state, NOT a place for functions or DOM nodes.
history.pushState(state: any, unused: string, url?: string | URL | null): void;
// 2. Replace the current entry in place — no new back-button stop.
history.replaceState(state: any, unused: string, url?: string | URL | null): void;
// 3. Fired on back/forward navigation (and history.go), never on pushState/replaceState.
interface WindowEventMap {
popstate: PopStateEvent; // event.state === the state object you stored
}
// 4. Opt out of the browser's automatic scroll guessing so you can restore manually.
history.scrollRestoration: 'auto' | 'manual';
The contract worth internalising: pushState and replaceState change the URL and the entry stack but do not fire popstate and do not load anything. Your code is solely responsible for rendering the matched view. The popstate event is your only signal that the user moved through history with the back or forward button, and its state payload is whatever you serialised at push time. Because the state object is passed through the structured clone algorithm, it must be plain data — storing a callback or an element throws a DataCloneError.
A robust navigation layer typically pairs these primitives with a matcher that turns a path string into a view plus extracted parameters. The matcher below preserves the trie approach from the original implementation because it gives O(k) lookup in the number of path segments rather than O(n) in the number of registered routes — the difference shows up on the main thread once a route table grows past a few dozen entries. The full treatment of comparison strategies lives in Route Matching Algorithms.
// TypeScript 5.x — framework-agnostic; ES2020+ runtime
// Trie-based matcher: O(k) in path depth, segment-by-segment.
interface RouteNode {
children: Map<string, RouteNode>;
handler?: () => Promise<void>;
isDynamic: boolean;
paramName?: string;
}
class TrieRouter {
private root: RouteNode = { children: new Map(), isDynamic: false };
addRoute(path: string, handler: () => Promise<void>): void {
const segments = path.split('/').filter(Boolean);
let current = this.root;
for (const seg of segments) {
const isDynamic = seg.startsWith(':');
const key = isDynamic ? ':' : seg;
if (!current.children.has(key)) {
current.children.set(key, {
children: new Map(),
isDynamic,
paramName: isDynamic ? seg.slice(1) : undefined,
});
}
current = current.children.get(key)!;
}
current.handler = handler;
}
match(path: string): { handler?: () => Promise<void>; params: Record<string, string> } | null {
const segments = path.split('/').filter(Boolean);
let current = this.root;
const params: Record<string, string> = {};
for (const seg of segments) {
if (current.children.has(seg)) {
current = current.children.get(seg)!; // static segment wins over dynamic
} else if (current.children.has(':')) {
const dynNode = current.children.get(':')!;
if (dynNode.paramName) params[dynNode.paramName] = decodeURIComponent(seg);
current = dynNode;
} else {
return null; // no match — caller should route to the fallback view
}
}
return current.handler ? { handler: current.handler, params } : null;
}
}
Step-by-Step Implementation
Prerequisite: a server (or static host) configured to serve index.html for any unmatched path, so a hard refresh on a deep URL reaches your client router instead of a 404 — this is the rewrite that Fallback Routing Strategies covers in depth.
Step 1: Intercept link clicks
Stop the browser from doing a full document load on internal links, and hand the URL to your router instead. Guard carefully so you never hijack modified clicks, downloads, or off-site links.
// TypeScript 5.x — framework-agnostic; ES2020+ runtime
function interceptLinks(onNavigate: (path: string) => void): void {
document.addEventListener('click', (event) => {
// Respect new-tab / download / right-click intents.
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = (event.target as HTMLElement).closest('a');
if (!anchor || anchor.target === '_blank' || anchor.hasAttribute('download')) return;
const url = new URL(anchor.href, location.href);
if (url.origin !== location.origin) return; // let the browser handle off-site
event.preventDefault();
onNavigate(url.pathname + url.search);
});
}
Step 2: Drive the URL with the History API
Wrap pushState so each navigation captures the outgoing view’s scroll and focus before the swap, then synchronises the URL bar with the rendered state. This wrapper builds on the original NavigationController but adds explicit state serialisation.
// TypeScript 5.x — framework-agnostic; Chrome 88+, Safari 14+, Firefox 85+
export class NavigationController {
private stateStack = new Map<string, { scrollY: number; focusTarget: string }>();
constructor() {
history.scrollRestoration = 'manual'; // we own scroll, not the browser
}
navigate(path: string, replace = false): void {
this.stateStack.set(location.pathname, this.captureState());
const method = replace ? 'replaceState' : 'pushState';
// Store only structured-cloneable data in the state slot.
history[method]({ path, ts: Date.now() }, '', path);
this.restoreFocusAndScroll(path);
}
private captureState() {
return {
scrollY: window.scrollY,
focusTarget: (document.activeElement as HTMLElement)?.id || 'main-content',
};
}
restoreFocusAndScroll(path: string): void {
const state = this.stateStack.get(path);
const top = state?.scrollY ?? 0;
window.scrollTo({ top, behavior: 'instant' });
const target =
(state && document.getElementById(state.focusTarget)) ??
document.querySelector('main');
(target as HTMLElement | null)?.focus({ preventScroll: true });
}
}
Step 3: Handle back and forward navigation
The popstate listener is the only place the browser tells you the user moved through history. Re-run the matcher and restore the saved scroll for that entry. The deeper edge cases here — Safari firing on initial load, hashchange interplay — are catalogued under Route Matching Algorithms and the matching primitives above.
// TypeScript 5.x — framework-agnostic; ES2020+ runtime
const router = new TrieRouter();
const nav = new NavigationController();
async function render(path: string): Promise<void> {
const matched = router.match(path);
if (!matched?.handler) {
await renderFallback(); // explicit 404 view — see Step 5
return;
}
await matched.handler();
}
window.addEventListener('popstate', async (event: PopStateEvent) => {
const path = location.pathname;
await render(path);
nav.restoreFocusAndScroll(path); // event.state holds what we pushed
});
Step 4: Synchronise crawler-visible metadata
Because no new document arrives, the title, description, and canonical link must be rewritten in JavaScript on every transition — and crucially before the framework’s hydration pass settles, so a crawler that snapshots early sees the right tags. This is the lever that makes When to Choose SPA over MPA for SEO a real decision rather than a coin flip.
// TypeScript 5.x — framework-agnostic; runs on every route transition
export function updateRouteMetadata(route: {
title: string;
description: string;
canonicalUrl: string;
}): void {
if (document.title !== route.title) document.title = route.title;
const upsert = (selector: string, create: () => HTMLElement, apply: (el: Element) => void) => {
let el = document.head.querySelector(selector);
if (!el) {
el = create();
document.head.appendChild(el);
}
apply(el);
};
upsert('meta[name="description"]', () => {
const m = document.createElement('meta');
m.setAttribute('name', 'description');
return m;
}, (el) => el.setAttribute('content', route.description));
upsert('link[rel="canonical"]', () => {
const l = document.createElement('link');
l.setAttribute('rel', 'canonical');
return l;
}, (el) => el.setAttribute('href', route.canonicalUrl));
window.dispatchEvent(new CustomEvent('route:updated', { detail: route }));
}
Step 5: Provide a real fallback for unmatched paths
A client router that renders a friendly page but returns 200 for an invalid URL produces a soft 404. The view must be paired with server config (or a static host’s 404 handling) that emits a genuine 404 status for paths your build cannot pre-render, so crawlers de-index them cleanly. Many production stacks reach for lazy boundaries to keep this glue cheap.
// react-router-dom v6.22 — lazy boundaries with an errorElement fallback
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouteObject, Outlet } from 'react-router-dom';
const Dashboard = lazy(() => import('./routes/Dashboard'));
const UserProfile = lazy(() => import('./routes/UserProfile'));
const NotFound = lazy(() => import('./routes/NotFound'));
const routes: RouteObject[] = [
{
path: '/',
element: (
<Suspense fallback={<div aria-live="polite">Loading route…</div>}>
<Outlet />
</Suspense>
),
children: [
{ index: true, element: <Dashboard /> },
{ path: 'users/:id', element: <UserProfile /> },
{ path: '*', element: <NotFound /> }, // catch-all; pair with a 404 status header at the edge
],
},
];
export const router = createBrowserRouter(routes, {
future: { v7_relativeSplatPath: true, v7_fetcherPersist: true },
});
Verification & Testing
Manual clicking proves nothing about the gaps an SPA reopens. Drive the real failure modes — focus, scroll, title, and back-button parity — with an end-to-end test.
// @playwright/test v1.44 — verifies SPA navigation restores the guarantees MPAs give free
import { test, expect } from '@playwright/test';
test('SPA navigation keeps URL, title and focus in sync', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Profile' }).click();
// URL changed without a full document load.
await expect(page).toHaveURL(/\/users\/\d+/);
// Title was rewritten for crawlers.
await expect(page).toHaveTitle(/Profile/);
// Focus moved into the new view, not stranded on the clicked link.
await expect(page.locator('main')).toBeFocused();
// Back button restores the previous view and its scroll.
await page.goBack();
await expect(page).toHaveURL('/');
// A bad path returns a real 404, not a soft 200 shell.
const res = await page.goto('/users/does-not-exist/nope');
expect(res?.status()).toBe(404);
});
For ad-hoc checks, the DevTools Console exposes everything you need: performance.getEntriesByType('navigation') confirms whether a transition was a real document load or a client swap, and history.state shows the payload your last pushState stored.
Performance Tuning
The SPA-versus-MPA performance story is a trade between first load and every subsequent navigation. Tune the side that matters for your traffic.
- Split by route, not by guess. Lazy-load each route’s component so first paint ships only the shell plus the landing view. Measure the initial bundle with your build’s analyser and keep the eager chunk under your interactivity budget; everything else loads on demand.
- Prefetch on intent. Trigger the dynamic
import()for a route on link hover orpointerdown, so the chunk is warm by the time the click lands. This recovers most of the MPA’s “instant because it’s already cached” feel without shipping it upfront. - Keep transitions off the main thread. Animate route changes with CSS
transformandopacityonly — they composite without layout — and defer non-urgent work (analytics, prefetch warming) behindrequestIdleCallbackso it never competes with the paint of the new view. - Instrument both navigation types. A
PerformanceObserverdistinguishes a cold document load from a warm client transition, which is the only honest way to compare the two architectures on your own traffic.
// TypeScript 5.x — Chrome 90+, Safari 15.4+, Firefox 100+
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'navigation') {
const nav = entry as PerformanceNavigationTiming;
console.log(
`Doc load (${nav.type}) — TTFB ${(nav.responseStart - nav.startTime).toFixed(0)}ms, ` +
`DCL ${(nav.domContentLoadedEventEnd - nav.startTime).toFixed(0)}ms`,
);
}
if (entry.entryType === 'paint' && entry.name === 'first-contentful-paint') {
console.log(`FCP ${entry.startTime.toFixed(0)}ms`);
}
if (entry.entryType === 'mark' && entry.name === 'hydration-complete') {
console.log(`Hydration ${entry.startTime.toFixed(0)}ms`);
}
}
});
observer.observe({ entryTypes: ['navigation', 'paint', 'mark'] });
performance.mark('hydration-complete'); // call from your framework entry point
Gotchas & Failure Modes
- Soft 404s drain crawl budget. A client router that answers every path with a
200shell teaches crawlers that nothing is broken. Always pair a catch-all view with a real404status at the edge for paths the build cannot resolve. - Metadata races hydration. If
updateRouteMetadataruns after the framework paints, an early crawler snapshot captures the previous route’s title. Run the meta update synchronously at the start of the transition, before awaiting data. - Scroll and focus bleed between views. The browser no longer resets either. Without explicit restoration, a long article scrolled to the footer hands its scroll offset to the next view, and keyboard users land wherever focus happened to be.
- State slot misuse throws. Passing a function, a DOM node, or a class instance to
pushStateraisesDataCloneError. Serialise to plain data and rehydrate on read. - Long sessions leak memory. SPA views that register listeners, timers, or fetch caches without tearing them down on unmount accumulate across a session that never reloads the document — the bloat an MPA avoids by discarding everything on each navigation.
- Hard refresh hits the server, not the router. A deep URL pasted into the address bar is a real request. Without the
index.htmlrewrite, it 404s before your client router ever runs.
Go Deeper
- When to Choose SPA over MPA for SEO — a decision framework for picking the architecture that survives search crawling, weighing rendering mode against crawl efficiency for content-heavy sites.
Related
- Routing Architecture & Fundamentals — the parent area covering how routing models shape bundle splitting, caching, and navigation.
- Route Matching Algorithms — comparison strategies behind the trie matcher, from naive regex to deterministic structures.
- Fallback Routing Strategies — server rewrites and catch-all handling that keep deep links and hard refreshes working.
- When to Choose SPA over MPA for SEO — when client-side rendering helps or hurts organic visibility, and the hybrid options in between.