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.
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?
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.
Intercept link clicks, call history.pushState, swap the view, done. Client-side routing is a solved problem and the router library handles it.
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).
- 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
titlestops 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.
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.pushStatecall 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
navigationAPI 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.
- 1Record 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,
pushStatestores a URL and nothing else; scroll position is the application's problem. - 2Tear 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).
- 3Show 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.
- 4Fetch 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.
- 5Set context
Updates the address bar and the document title, which drives the tab, bookmarks, history entries and screen-reader announcement.
fails by Forgotten
document.titleupdates are the most common SPA regression, and they degrade history and bookmarks as well as announcement. - 6Reset 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).
- 7Render
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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| User presses back after scrolling a long list | They land at the top of the list and lose their place | The history entry recorded a URL but not the scroll offset, and the list is re-rendered empty then filled | Store scroll position per history entry and restore it after the data that determines page height has rendered (Scroll Restoration). |
| Keyboard user activates a navigation link | Tab order restarts from the top of the document, or from nowhere | The activated element was removed while focused, so focus fell back to the body | Move focus to the new view's heading or main landmark as part of the route transition (Focus Management). |
| Screen-reader user changes route | Silence — no indication that anything happened | No document change, no title change, no live region update | Update the title and announce the new view politely; treat announcement as part of routing (Live Regions and Announcement). |
| Slow route with a data dependency | The old screen sits there looking clickable, then everything changes at once | No loading boundary between match and render, so the transition is invisible until it completes | Render a loading boundary at the route level, and make the pending state both visible and announced (Route Loading Boundaries). |
| User navigates three times quickly | The screen briefly shows content from a route they already left | In-flight requests from abandoned routes resolve after the current one | Cancel on route change, or discard responses whose route key no longer matches (Cancelling a Request Nobody Is Waiting For). |
| A route handler throws | The whole application is stuck until a manual reload | Nothing tears down, so a bad state persists across every later navigation | Error boundaries per route, with a recovery path that resets the state the route owned (Frontend Error Tracking). |
And what the SPA genuinely buys
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.
- MPA: document 1 — Request and stream the first document.
- MPA: render 1 — Style, layout, paint on a small document.
- MPA: nav 2 — Another round trip; cached subresources are reused.
- MPA: nav 3 — The per-navigation cost never shrinks.
- MPA: nav 4 — Constant cost per screen, no accumulation.
- SPA: shell — Small HTML, then the bundle.
- SPA: bundle — Everything the first screen needs, plus whatever was not split out.
- SPA: parse + execute — Main-thread work that scales with the device, not the network (The Real Cost of JavaScript).
- SPA: render 1 — First screen finally appears.
- SPA: nav 2 — Route match and reconcile; data may already be cached.
- SPA: nav 3 — State from screen 1 is still in memory.
- SPA: nav 4 — This 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
- 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).
- 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.
- 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
fetchand 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).
- "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
- 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).
- 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).
- 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.
- — 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.