ArchitectureGENERALSPEC-EVOLVINGFRAMEWORK-SPECIFIC

MPA vs SPA

An MPA gets history, scroll, focus reset, back and forward, and per-page code loading from the browser. A SPA must rebuild all of it — and buys preserved state and richer transitions in return.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What exactly does the browser do for a multi-page application that a single-page application has to reimplement, and what does the SPA get in exchange?

The user intent

A person clicks a link. They expect to arrive somewhere new, to be able to come back with the browser's back button, to land where they were on the page, and — if they were halfway through typing something — for that not to have been thrown away.

The obvious build

Intercept link clicks, call history.pushState, swap the view, done. Client-side routing is a solved problem and the router library handles it.

Why it breaks

The back button now restores the URL but not the scroll position, so returning to a long list drops the user at the top and they have to find their place again (Scroll Restoration).

How it breaks in a real browser
  • The back button now restores the URL but not the scroll position, so returning to a long list drops the user at the top and they have to find their place again (Scroll Restoration).
  • Focus stays wherever it was. A keyboard user activates a link, the content changes, and their focus is on a control that no longer exists in the document — with nothing announced (Focus Management).
  • The browser's loading indicator never appears, because from the browser's point of view nothing is loading. A slow route transition is indistinguishable from a page that has stopped responding.
  • The document title stops changing, so the tab, the history entries and the bookmark all say whatever the shell said, and screen readers announce nothing on navigation.
  • Every route's code is in one bundle unless somebody deliberately splits it, so a person who only ever visits the login page still downloads the reporting module (Code Splitting).
  • Errors accumulate. In an MPA a broken page is one broken page; in a SPA a bad state persists across every subsequent route until the user reloads by hand.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A document navigation is a browser-level operation. The browser tears down the current document — listeners, timers, JavaScript heap, DOM — creates a new one, and reruns the whole pipeline from bytes to pixels (What Happens When You Open a Website).
  • Because it is a browser operation, the browser also does the surrounding work: it records a history entry with the scroll position, resets focus to the document, updates the address bar, shows progress, sets the title from the new document, and fetches exactly the subresources the new document references.
  • A client-side navigation is not a navigation. It is a history.pushState call plus DOM mutation, in the same document, with the same heap. The browser has no way to know that the meaning of the page changed (History and Navigation).
  • So everything the browser previously did on your behalf becomes application code: matching the URL to a view, deciding what data to load, deciding what to show while it loads, setting the title, moving focus, restoring scroll, and handling the case where the user navigates again before the first one finishes.
  • In exchange, nothing is torn down. Module-level state, open WebSocket connections, an in-memory cache of fetched data, a half-filled form, an editor's undo stack — all survive, because the document survives (Server State Is Not Your State).
  • The navigation API and cross-document view transitions are gradually returning some of this to the platform, which shifts the trade over time without erasing it (Client-Side Routing).

What this makes the browser do

And which of it is avoidable.

  • MPA per navigation: a request, a parse of a fresh document, style, layout, paint, and script compile for that page's scripts only. Repeated work, but bounded and self-cleaning.
  • SPA per navigation: JavaScript executes a route match, possibly fetches data, reconciles a component tree and mutates the DOM. Usually much less work — but all of it on the main thread, competing with everything else (What the Main Thread Owns).
  • MPA teardown is free and complete: the old document's memory goes away whether or not anyone remembered to remove a listener.
  • SPA teardown is manual. Every subscription, timer, observer and cache entry that a route created is retained until code removes it, which is why route changes are where frontend leaks are found (Memory Leaks).
  • MPA loading is per-page and automatic; the browser requests what the document references. SPA loading is per-bundle and deliberate; without route-level splitting the first visit funds every route (Lazy Loading).

What a document navigation actually includes

It is easy to describe a document navigation as "the page reloads", which makes it sound like one coarse operation with one cost. It is not one operation. It is a sequence of things the browser does on your behalf, and a client-side router replaces the whole sequence with one step — swapping the DOM — and then has to add the rest back by hand.

Listing the sequence is the fastest way to see what a router owes. Every step below has a client-side equivalent that somebody has to write, and the ones that are usually missing are the ones no mouse user will ever report.

One click on a link, in an MPA
  1. 1
    Record history

    Stores a history entry for the current document, including its scroll position, so back returns to exactly where the user was.

    fails by In a SPA, pushState stores a URL and nothing else; scroll position is the application's problem.

  2. 2
    Tear down

    Destroys the document: listeners, timers, observers, the JavaScript heap and the DOM all go away together.

    fails by In a SPA nothing is destroyed, so every route must clean up after itself or leak (Memory Leaks).

  3. 3
    Show progress

    The browser indicates that a navigation is under way, and offers a stop control.

    fails by A client-side route change is invisible to the browser, so the user has no signal that anything is happening unless you provide one.

  4. 4
    Fetch and parse

    Requests the new document and streams it into a DOM, discovering and prioritising exactly the subresources this page needs (The Preload Scanner).

    fails by A SPA fetches data, not documents, and its code is already in a bundle that was chosen at build time rather than per page.

  5. 5
    Set context

    Updates the address bar and the document title, which drives the tab, bookmarks, history entries and screen-reader announcement.

    fails by Forgotten document.title updates are the most common SPA regression, and they degrade history and bookmarks as well as announcement.

  6. 6
    Reset focus and scroll

    Puts focus at the start of the new document and scroll at the top, or at the fragment target if the URL has one.

    fails by Focus stays on a removed element, so keyboard users are stranded and the next Tab starts from an unpredictable place (Focus Management).

  7. 7
    Render

    Runs the full pipeline on a clean document: style, layout, paint, composite (The Rendering Pipeline).

    fails by A SPA reconciles into an existing tree, so stale styles, leftover portals and previous route DOM can survive the transition.

Seven behaviours, one of which — the DOM swap — is the one client-side routers implement first and the only one users notice immediately.

The gaps, and who notices them

Each of these is a small omission with a specific victim. That is what makes them persistent: they are individually cheap to ignore, they are all invisible on the machine the developer is using, and no single one of them is dramatic enough to become a priority on its own.

The pattern worth taking away is that the browser's navigation behaviours are not conveniences. They are the accessibility and robustness contract of the web, and taking ownership of navigation means taking ownership of that contract too.

What breaks when a router only swaps the DOM
TriggerSymptomCauseResponse
User presses back after scrolling a long listThey land at the top of the list and lose their placeThe history entry recorded a URL but not the scroll offset, and the list is re-rendered empty then filledStore scroll position per history entry and restore it after the data that determines page height has rendered (Scroll Restoration).
Keyboard user activates a navigation linkTab order restarts from the top of the document, or from nowhereThe activated element was removed while focused, so focus fell back to the bodyMove focus to the new view's heading or main landmark as part of the route transition (Focus Management).
Screen-reader user changes routeSilence — no indication that anything happenedNo document change, no title change, no live region updateUpdate the title and announce the new view politely; treat announcement as part of routing (Live Regions and Announcement).
Slow route with a data dependencyThe old screen sits there looking clickable, then everything changes at onceNo loading boundary between match and render, so the transition is invisible until it completesRender a loading boundary at the route level, and make the pending state both visible and announced (Route Loading Boundaries).
User navigates three times quicklyThe screen briefly shows content from a route they already leftIn-flight requests from abandoned routes resolve after the current oneCancel on route change, or discard responses whose route key no longer matches (Cancelling a Request Nobody Is Waiting For).
A route handler throwsThe whole application is stuck until a manual reloadNothing tears down, so a bad state persists across every later navigationError boundaries per route, with a recovery path that resets the state the route owned (Frontend Error Tracking).

And what the SPA genuinely buys

SIMPLIFIEDA schematic with prefetching, streaming, caching and code splitting all left out, each of which moves the crossover point substantially — a SPA that prefetches route chunks on hover and an MPA served from an edge cache both look considerably better than this shape suggests.

The trade runs in both directions, and the SPA side of it is real. Once the application is loaded, a route change can be a main-thread operation with no network round trip at all — and everything the user has accumulated stays accumulated. For a product where a person moves between screens dozens of times in a session while holding context, that is not a nicety; it is the product working.

The timeline below is shape, not measurement: the units are relative and their only job is to show which costs land where. The SPA pays a large one-off cost so that later navigations are cheap; the MPA pays a small cost every time and never pays a large one. Which is better is a question about how many navigations a session contains and how fast the user's device is.

First visit, then three navigationsrelative units — schematic shape only, not measured timings
MPA: document 1
MPA: render 1
MPA: nav 2
MPA: nav 3
MPA: nav 4
SPA: shell
SPA: bundle
SPA: parse + execute
SPA: render 1
SPA: nav 2
SPA: nav 3
SPA: nav 4
  • MPA: document 1Request and stream the first document.
  • MPA: render 1Style, layout, paint on a small document.
  • MPA: nav 2Another round trip; cached subresources are reused.
  • MPA: nav 3The per-navigation cost never shrinks.
  • MPA: nav 4Constant cost per screen, no accumulation.
  • SPA: shellSmall HTML, then the bundle.
  • SPA: bundleEverything the first screen needs, plus whatever was not split out.
  • SPA: parse + executeMain-thread work that scales with the device, not the network (The Real Cost of JavaScript).
  • SPA: render 1First screen finally appears.
  • SPA: nav 2Route match and reconcile; data may already be cached.
  • SPA: nav 3State from screen 1 is still in memory.
  • SPA: nav 4This is what the first-load cost bought.

The crossover point is the whole argument, and it moves with device speed, network latency, session length and how aggressively the bundle is split. Nobody can tell you where it is for your product without field data (Real User Monitoring).

How to build it

Most important first.

  • If you own navigation, own all of it. Treat title, focus, scroll, announcement, loading state and error recovery as required parts of the router, not as polish — because the browser did all of them and users noticed when they stopped (Route Loading Boundaries).
  • Move focus deliberately on route change: to the main landmark or the new page heading, so keyboard and screen-reader users land where sighted users are already looking (Focus Management).
  • Restore scroll on history traversal and reset it on new navigations. Those are different cases and conflating them is why "back" lands in the wrong place (Scroll Restoration).
  • Split code by route from the beginning. Retrofitting splitting into a SPA that assumed one bundle is a refactor of every import in the project (Code Splitting).
  • Keep the MPA where it is winning. A product can server-render everything except the one screen that genuinely needs a long-lived client, and that is usually the cheapest correct answer (Choosing a Frontend Architecture).
  • Make the URL carry enough state that a full reload reproduces the screen. If it does, you have kept the property that makes MPAs robust, and every SPA bug becomes recoverable by refresh (The URL Is Application State).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • A document navigation announces itself. Assistive technology treats a new document as a new context, reads the title, and puts the user at the top. None of that is automatic for a client-side route change (Live Regions and Announcement).
  • The minimum honest SPA route change is: update document.title, move focus to the new content's heading or main landmark, and ensure the heading structure of the new view is correct. Without focus movement, keyboard users have no way to reach the content that just appeared.
  • Loading states need announcement, not just a spinner. A visual spinner with no accessible status means a screen-reader user hears nothing between activating a link and the content appearing (Loading, Error, Empty — The States You Did Not Render).
  • The browser's own behaviours are accessibility features that predate every framework: back and forward, reload, open-in-new-tab, and the fact that a link is a link. Intercepting a click on something that is not an anchor removes middle-click, modifier-click and the context menu for everyone (Semantics Are Behaviour).

What can go wrong

Failure modes
  • Partially reimplemented navigation: history works, scroll does not; title updates, focus does not. Each piece is small, so each is deferred, and the aggregate is a product that is subtly hostile to keyboard users.
  • Two navigations in flight. The user clicks through three routes quickly, the responses come back in a different order, and the last one to arrive wins regardless of which one the user is on (Out-of-Order Responses).
  • An error in a route handler leaves the shell in a broken state that survives every subsequent navigation, because nothing is ever torn down.
  • The mitigation failing: a focus-management implementation that grabs focus on every render rather than every navigation, which is worse than not moving focus at all — it steals focus while a user is typing.
  • In the MPA direction: a form that posts, fails validation and re-renders, losing everything the user typed because the server did not echo the values back (Submission: Method, Encoding and Doing It Once).
What can arrive out of order
  • Rapid successive route changes: three data requests are in flight and they resolve in an order the network chose. Without cancellation or a last-write-wins guard keyed to the current route, an old response paints over the current screen (Cancelling a Request Nobody Is Waiting For).
  • A route transition racing a code chunk: the route matched, the component's chunk has not arrived, and the user has already navigated on. A loading boundary that assumes it will be mounted when its chunk arrives will update a view that no longer exists.
  • History traversal racing data restoration: the browser restores the URL immediately and the data arrives later, so the intermediate frame shows the new URL with the old content.
Security
  • A document navigation is a clean boundary: the previous document's JavaScript, and anything injected into it, is gone. A SPA keeps a compromised context alive for the life of the tab, which makes an XSS foothold far more valuable to an attacker (Cross-Site Scripting).
  • Client-side routing does not hide routes. The route table is in the bundle, and a route that renders only for administrators is a UI decision that the server must independently enforce (Authorization-Aware UI).
  • Session expiry behaves differently: an MPA discovers it on the next navigation and redirects; a SPA discovers it on the next fetch and must handle it without losing the user's in-progress work (Session Expiry and the Refresh Race).
  • MPAs post forms, which means cookies are sent automatically and cross-site request forgery is a live concern on every state-changing form (Cross-Site Request Forgery).
Misreads
  • "SPAs are faster." Faster at navigating between screens once loaded, on the same device and network; slower to become usable at all, sometimes by a lot. Which half a user feels depends on whether they visit once or fifty times.
  • "Client-side routing is a solved problem, the library handles it." Libraries handle URL matching and rendering. Focus, announcement, scroll restoration and title are usually left to you, and are exactly what breaks.
  • "MPAs mean a full reload on every click, so they are obsolete." They mean the browser does the reload, correctly, and can serve it from cache. On a fast connection with a small document that is frequently the better experience (Browser HTTP Caching).
  • "We are a SPA, so we do not need server-rendered HTML." The two are independent choices: you can server-render a client-routed application, and most serious ones do (Server-Side Rendering).
  • "Preserved state is always a win." It is also preserved staleness, preserved memory, and preserved bugs. Every MPA navigation is a free reset that a SPA has to simulate.

Measuring it, and what changes in the field

How you would see this
  • The Network panel on a route change: an MPA shows a document request, a SPA shows data requests or nothing at all. That difference tells you which model you are actually in on any given click.
  • Interaction latency in field data separates the two honestly — the SPA route change is a main-thread cost, the MPA navigation is a network cost, and users feel both (Interaction Responsiveness).
  • Memory across repeated route changes is the SPA-specific measurement. Navigate a loop of five routes twenty times and watch retained size; a flat line is a working teardown (Debugging Memory).
  • A keyboard pass is the fastest test of everything in this lesson: tab to a link, activate it, and see where focus is. If the answer is "wherever it was", the router is unfinished (Keyboard Operability).
Slow device, slow network, large data, old tab
  • On a high-latency network the MPA pays a full round trip per navigation and the SPA can feel dramatically better, especially with prefetching on hover or viewport entry (Resource Hints).
  • On a slow device the comparison inverts: the SPA's first load must parse and compile the whole application before anything works, and script execution is where slow devices lose most (The Real Cost of JavaScript).
  • On a large dataset, the SPA's ability to keep a fetched collection in memory across navigations is a genuine and large win the MPA cannot match without a cache of its own.
  • In a long session the SPA accumulates state and the MPA does not; after an hour of use they are different applications, and only one of them has been tested that way (Long-Lived Clients and Version Skew).
What this costs
  • Owning navigation buys preserved state and smooth transitions and costs you a permanent maintenance surface: every browser behaviour you replaced is now a thing that can regress, and most of the regressions are invisible to mouse users.
  • Staying with document navigation costs a round trip per screen and the loss of in-memory state, and buys correctness you get for free and cannot break.
  • The hybrid — server-rendered documents with client-side navigation enhancing them — gets a great deal of both, and costs you two navigation models running in the same product, each of which can be the one that is broken.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALWhat the browser does on a document navigation — history entry with scroll position, focus reset to the document, title change, per-document subresource loading, complete teardown — is specified behaviour and is consistent across Blink, Gecko and WebKit. What a client-side router does about it is entirely up to the router.
  • SPEC-EVOLVINGThe Navigation API and cross-document view transitions are moving parts of this back into the platform, with support arriving at different times in different engines; where they are available the SPA has less to reimplement, so advice written against today's gaps will overstate the cost in a few years.
  • FRAMEWORK-SPECIFICSome routers restore scroll and set focus by default while others deliberately do neither and document that it is the application's job; assuming your router's behaviour is universal is how a migration between two of them silently removes accessibility behaviour.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Securitycsrf
Domains that do not exist yet
  • Software Design — a client-side router is a state machine with a history stack, and the bugs in this lesson are almost all missing transitions in that machine rather than missing features.