Single-page vs multi-page apps
Whether each click loads a new document from the server or the app swaps views inside one page: a choice that shapes first load, navigation speed, memory over long sessions, the back button, and accessibility.
Two ways to go from one page to the next
Picture a shoe shop. A shopper lands on the home page, searches for running shoes, opens a product, adds it to the cart, and goes to checkout. That is five pages and four clicks. Then she presses Back twice to look at the product again.
There are two very different ways to build those four clicks. In the first, every click asks the server for a brand new HTML document. The browser throws the old page away and builds the new one from nothing. In the second, the browser loads one document at the start, and JavaScript swaps the content inside it on every click, fetching only the data it needs.
The first is a multi-page app (MPA): Wikipedia, classic Rails, Django, PHP, and WordPress sites, and most Astro sites. The second is a single-page app (SPA): Gmail, Figma, Linear, and most React dashboards. Frameworks like Next.js, Nuxt, and SvelteKit mix the two: the first page is a full server-rendered document, and later clicks are handled in the page.
What the choice decides
It sounds like a detail of the router, but it decides a lot: how fast the first page and the later clicks are, what survives a click (a playing video, a half-typed comment, an open connection), how memory behaves over an eight-hour session, and who handles the back button, scroll, and focus. Each model gets some of these for free and has to work hard for the others.
Why the old answer changed
Around 2015 the usual advice was simple: if the product is an app, build an SPA, because MPA navigations feel slow and cannot animate. Three browser features have weakened that argument since then.
- The back/forward cache (bfcache) keeps a page you left frozen in memory, so Back and Forward restore it almost instantly, with its scroll position and JavaScript state intact.
- Speculation Rules let an MPA tell Chromium browsers to fetch, or even fully render, the next page in a hidden tab before the click. When the user clicks, the page is already there.
- Cross-document View Transitions let an MPA animate from one page to the next with a few lines of CSS, which used to be the clearest reason to pick an SPA.
Meanwhile teams learned the hidden costs of big SPAs: large bundles, slow first loads on mid-range phones, memory that grows all day, broken focus, and users stuck on old code after a deploy. So the question today is not "which is modern?" but "which costs do you want to pay, for this product and these users?".
The mental model: who owns the navigation
The one question that separates the two models is: who handles the click? In an MPA, the browser does. In an SPA, your JavaScript does. Everything else in this lesson follows from that.
An everyday comparison helps. An MPA is a printed brochure: to see the next page you take a new sheet from the stack, and the old sheet goes away. An SPA is a whiteboard: you keep the same board and erase and redraw parts of it.
A new sheet is always clean, but fetching it takes a moment. The whiteboard is fast to change, but every smudge you never wipe stays there all day.
A document navigation (the MPA model)
When the user clicks a normal link, the browser runs a document navigation. These are the steps, in order:
- The browser fires
beforeunload, then requests the new URL. The old page stays on screen while it waits. - The response arrives and the browser commits: the URL bar changes and the old page gets
pagehide. It is frozen into the back/forward cache if eligible, or destroyed and its memory freed. - The browser parses the new HTML, loads its CSS and scripts (shared files usually from the HTTP cache), and paints.
- Focus moves to the top of the new document, a screen reader announces the title, and a history entry makes Back work with no code.
On a typical 4G phone, a click like this takes roughly 300 to 1,200 ms to show the next page: one round trip for the HTML, the server's own time to build it, then parse and paint. During that time the old page is frozen on screen, and on slow pages users see a white flash between the two.
A same-document navigation (the SPA model)
In an SPA, a client-side router listens for clicks on links. When one happens, it cancels the browser's navigation with preventDefault(), changes the URL with history.pushState(), and renders the new view in the same document. The browser calls this a same-document navigation: the URL changes, a history entry is added, but no new document is loaded.
- The router matches the URL against its route table.
- It downloads the route's code chunk if needed (see Code splitting) and fetches missing data, usually as JSON.
- It renders the new view in place. The header, sidebar, and anything outside the route stay mounted.
- It must now do by hand what the browser did for free: set
document.title, move focus, announce the change, and set the scroll position.
When the code and data are already in memory, this whole loop takes 20 to 80 ms, mostly rendering. When data must be fetched, it takes one API round trip plus rendering, which is often similar to an MPA click, but with a loading state the app controls instead of a frozen page.
The document is the unit of everything
Many browser features are tied to the document, not to the URL. The JavaScript heap, timers, event listeners, open connections, the focus position, the scroll position, the page title, and the history entry all belong to a document. An MPA gets a new document per page, so all of these reset on every click. An SPA keeps one document for the whole session, so none of them reset unless your code resets them.
That one fact explains almost every trade-off in the rest of this lesson. It is why an SPA keeps a music player playing across pages, why it can leak memory for hours, and why it has to move focus by hand.
Check your understanding · Exercise 1
A Next.js App Router site serves the first page as server-rendered HTML. The user then clicks a Link to another page. Which statements are true? Select all that apply.
How client-side routing works
A client-side router looks simple: catch the click, change the URL, render. The work is in the details, because the router has to copy browser behavior that users expect without thinking about it. This chapter walks through each piece and the mistake people make with it.
The History API: pushState and popstate
Every browser has the History API. history.pushState(state, "", url) adds a history entry and changes the URL without loading anything. history.replaceState() changes the current entry instead, which suits a filter change that should not create a Back step. When the user presses Back or Forward between entries of the same document, the browser fires popstate, and the router renders the view for the new URL.
The History API has gaps. pushState fires no event, and link clicks, form submits, script calls, and Back each arrive through a different door. Routers like React Router and Vue Router exist largely to cover these gaps.
The Navigation API: one event for every navigation
The newer Navigation API fixes this with one navigate event on the global navigation object, fired for link clicks, form submissions, Back and Forward, and script calls. A router calls event.intercept() to turn it into a same-document navigation, and gets the URL, the kind of navigation, and a signal that aborts if the user navigates again. Chromium shipped it in 2022 (Chrome 102); Safari 26.2 and Firefox 147 followed around the start of 2026, so routers still keep a History API fallback for older browsers.
navigation.addEventListener("navigate", (event) => {
const url = new URL(event.destination.url);
// Leave downloads, other origins, and hash jumps to the browser.
if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
const route = matchRoute(url.pathname);
if (!route) return; // unknown path: let a full page load handle it
event.intercept({
async handler() {
const data = await route.load(url, { signal: event.signal });
render(route.view, data);
document.title = route.title(data);
focusMainHeading(); // see Focus management
},
});
});
Which clicks a router must leave alone
With the History API, routers catch clicks themselves, and a common bug is catching too many. A router must let the browser handle a click when:
- a modifier key is held (
Ctrl,Cmd,Shift,Alt) or the middle button is used: the user wants a new tab or window; - the link has
target="_blank", adownloadattribute, or points to another origin; - the link is only a hash on the same page, like
#reviews; - the path is outside the app, for example
/adminserved by a different system.
The Navigation API handles most of these for you through canIntercept, downloadRequest, and hashChange. A div with an onClick that calls router.push() is worse than any of these bugs: it has no URL to copy, cannot open in a new tab, and is not announced as a link. Use real a elements with real href values, always.
Scroll position and data loading
Browsers restore scroll on Back for document navigations. For same-document ones they try too, but they restore it immediately, before the router has fetched and rendered the old view, so the page jumps to the top. Routers set history.scrollRestoration = "manual", save the scroll position per history entry, and restore it after the view renders. New pushes should scroll to the top; Back should return to where the user was.
Data loading has two common shapes. Render then fetch shows the new view at once with skeletons, then fills it in. Fetch then render keeps the old view, with a thin progress bar, until the data is ready.
Router loaders in React Router and SvelteKit work the second way by default. A common refinement keeps the old view for a moment and shows skeletons only if the data is slow, which avoids a flash of skeletons on fast connections.
Check your understanding · Exercise 2
Users report problems with a hand-written SPA router. Which of these are bugs in the router rather than expected behavior? Select all that apply.
What each model gets for free
Each model gets some qualities from the browser with no code at all, and has to build the rest. Knowing which list you are on tells you where your team's effort will go.
What an MPA gets for free
- A clean slate per page. Heap, timers, and listeners are thrown away on every navigation, so a leak in search cannot slow down checkout.
- History, scroll, and focus. Back, Forward, scroll restoration, focus reset, and the page announcement need no code.
- The back/forward cache. Back and Forward can restore the previous page from memory in a few milliseconds.
- Progressive enhancement. Links and forms work before any JavaScript runs, or if it fails.
- Small first loads. Each page ships only the script it needs.
- Fresh code on every click. After a deploy, the next click loads the new version.
What an SPA gets for free
- State that survives navigation. A playing video, an open chat panel, a draft, a websocket, and cached API data stay alive across clicks.
- Fast later clicks. The code is loaded and the data is often cached: 20 to 80 ms is common.
- Full control of transitions. The app decides what stays, what shows a skeleton, and what animates.
- Easy offline. A service worker caching one app shell lets the whole app start with no network (see Service workers).
- A clean API boundary. The same JSON APIs can serve the web app, the mobile apps, and partners.
Read the two lists side by side and a pattern appears: the MPA list is about correctness and resilience, and the SPA list is about continuity and speed after the first load. The matrix below puts both against the same questions, including what each model can get with extra work.
| Concern | MPA | SPA |
|---|---|---|
| First visit from search | Fast: full HTML, small scripts | Slow unless server-rendered first |
| Later click, code and data cached | 300 to 1,200 ms, or near 0 with prerender | 20 to 80 ms |
| Back and Forward | bfcache restore in a few ms | Router re-renders from its cache |
| Memory after 8 hours | Same as after 1 minute | Grows with every leak |
| Audio, video, chat across pages | Restarts on each page | Keeps running |
| Focus and announcements | Browser does it | App must do it |
| Works if JavaScript fails | Mostly yes | Usually no |
| New deploy reaches the user | Next click | Next full reload, maybe days later |
Lab: the same five-page flow, two ways
The lab below walks the shoe shop flow: Home, Search, Product, Cart, Checkout, then two Backs. Switch between MPA and SPA, and turn on prerendering, the back/forward cache, and view transitions. Watch three things: the time each step takes, the memory the tab holds, and what survives the click (the scroll position, the shoe size picked on the product page, and a mini video player in the corner).
The numbers come from a rough model of a mid-range phone on 4G: they show the shape of each trade-off, not measurements.
First load of Home: 1100 ms, first load. Tab memory about 60 MB. Waiting after the first load: 0 ms in total.
| Thing on the page | Survives? | Why |
|---|---|---|
| Shoe size picked on Product | no | lost on Back: page rebuilt, scripts start over |
| Scroll on Back | yes | browser restores it |
| Mini video player | no | restarts on every page |
| Cart count in header | yes | rendered fresh by the server |
Notice what the toggles do to the MPA. Prerender makes forward clicks almost instant, and the bfcache makes the Backs almost instant and keeps the picked shoe size. View transitions animate the steps instead of flashing.
What the MPA never gets is the video that keeps playing. Anything that must keep running across pages is the strongest reason for an SPA, or an SPA-like shell around that one feature.
Check your understanding · Exercise 3
Which of these does a plain MPA get with no JavaScript written for it? Select all that apply.
View transitions: animating between pages
For years, animation between pages was the SPA's clearest win: it has both views in one document, while an MPA throws the old document away before the new one exists. The View Transitions API changes that for both models, with one shared idea.
The idea: snapshot, swap, animate
A view transition works in three steps. The browser takes a picture of the old state. Then the DOM changes, either by your callback or by a new document arriving. Then the browser shows the old picture and the new live view on a layer above the page and animates between them.
By default that is a 250 ms cross-fade. Your CSS can change the animation, and you can name elements so they move from their old position to their new one.
::view-transition-old(name)is the picture of the old state for that name. It is a flat image: it no longer responds to clicks.::view-transition-new(name)is the live new state for that name.view-transition-name: product-photoon one element in each state makes the browser treat them as the same thing, and animate the position and size from one to the other. The name must be unique on the page at that moment, or the transition is skipped.- The whole page is named
rootby default, which is where the cross-fade comes from.
@view-transition only animates what comes after.Same-document transitions (SPAs)
In an SPA you wrap the DOM update in document.startViewTransition(() => updateTheDom()). The browser snapshots, runs your callback, waits for its promise, and animates. Chrome shipped this in version 111 in 2023, Safari in 18.0 in 2024, and Firefox in 144 in October 2025. Frameworks wrap it for you: React Router has a viewTransition option on links, SvelteKit's onNavigate hook is the documented place to start one, and Astro's ClientRouter uses it.
Cross-document transitions (MPAs)
For an MPA, both pages opt in with one CSS rule, and the browser does the rest on every same-origin navigation between them. Chrome shipped this in version 126 in 2024 and Safari in 18.2 at the end of 2024; Firefox has not shipped it yet. Browsers without support simply navigate as before, so it is a safe progressive enhancement.
@view-transition {
navigation: auto;
}
/* list page: only the clicked card gets the name (set from pageswap) */
/* detail page: */
.product-hero {
view-transition-name: product-photo;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none;
}
}
Two events let script take part. pageswap fires on the old page just before it is replaced: set a view-transition-name on the clicked card there. pagereveal fires on the new page before its first frame: adjust the transition there. A link with rel="expect" and blocking="render" can hold the first frame until a named element has been parsed.
What view transitions do not fix
A transition only animates. It does not make the new page arrive sooner. For a cross-document transition, the old page stays on screen, frozen, until the new one is ready to paint, so a 900 ms server response is still a 900 ms wait, now followed by a 250 ms animation. Pair transitions with prerendering (next chapter) or the animation makes slow pages feel slower.
- Same origin only. Cross-document transitions never run across origins.
- Short. The animation layer sits on top of the page and takes no clicks, so keep it to 200 to 400 ms.
- Reduced motion. Respect
prefers-reduced-motion, as in the code above. - Unique names. Two elements with the same
view-transition-nameat once abort the transition, so in a list name only the clicked item.
Check your understanding · Exercise 4
A team adds @view-transition { navigation: auto; } to every page of their MPA. Which outcomes should they expect? Select all that apply.
Making MPAs instant: bfcache and prerendering
The main complaint about MPAs is the wait between pages. Two browser features attack that wait from both ends. The back/forward cache makes going back instant, and Speculation Rules prerendering makes going forward instant. Together they cover most clicks a user makes.
The back/forward cache
When a user leaves a page, the browser can keep it in memory instead of destroying it: the DOM, the JavaScript heap, the scroll position, the text in form fields. Script, timers, and promises are paused. On Back or Forward, the browser wakes the page up: no request, no parse, no render, just a few milliseconds.
This matters more than it sounds. Chrome's data, published on web.dev, shows that about 1 in 10 navigations on desktop and 1 in 5 on mobile are Back or Forward. Every one of them can be instant, for free, if the page is eligible.
pageshow with persisted to refresh stale bits.Your code sees the lifecycle through two events. pagehide fires when the user leaves, with event.persisted set to true if the page might go into the cache. pageshow fires when it comes back, with event.persisted set to true if it came from the cache. That is the place to refresh things that may be stale, like a cart count or a signed-in state, and to reopen connections you closed in pagehide.
What keeps a page out of the bfcache
A page can only be frozen if it is safe to do so. The browser refuses when freezing could break the page or leak data. The most common reasons, and their fixes:
- An
unloadlistener. It cannot run for a page that might come back, so desktop Firefox refuses to cache a page that has one, even if a third-party script added it. Desktop Chrome did the same until it finished removingunloadin 2026. Usepagehideorvisibilitychangeinstead. Cache-Control: no-storeon the HTML. For years this kept pages out of the bfcache. In 2025 Chrome rolled out a change that lets mostno-storepages in, but it evicts them if the site's cookies change while cached (for example, after a logout) and keeps them for only 3 minutes. Other browsers may still refuse them, so useno-storeonly where it is truly needed.- Open connections. An open WebSocket kept pages out of Chrome's bfcache until Chrome 149 (2026), which instead closes the socket as the page is frozen; an open WebRTC connection can still block it, and other browsers differ. Either way, close connections in
pagehideand reopen them inpageshow. window.openerlinks and long IndexedDB transactions. Userel="noopener"and keep transactions short.
To check a page, open Chrome DevTools, go to Application, then Back/forward cache, and run the test. In the field, Chrome reports the reasons through performance.getEntriesByType("navigation")[0].notRestoredReasons, which you can send to your analytics. Try the checker below to see how each blocker, and each fix, changes the result.
Not restored: Back is a full page load, about 550 ms.
- an unload listener: desktop Firefox refuses the page (Chrome stopped running unload in 2026); use pagehide instead
- an open WebSocket: some browsers refuse the page, and Chrome 149+ closes it for you, so the page must reconnect anyway; close it in pagehide, reopen in pageshow
A simplified model of current Chrome and Firefox; the times are illustrative, like the lab above.
Prerendering the next page with Speculation Rules
Going forward is harder, because the browser cannot know where the user will click. Speculation Rules let the page tell it. A script element with type="speculationrules" holds JSON that lists URLs, or URL patterns, to prefetch (download only the HTML) or to prerender (load and render the whole page in a hidden tab, scripts included).
When the user clicks a prerendered link, the browser swaps the hidden page in. The click takes a few milliseconds instead of hundreds.
<script type="speculationrules">
{
"prerender": [{
"where": {
"and": [
{ "href_matches": "/products/*" },
{ "not": { "selector_matches": ".no-prerender" } }
]
},
"eagerness": "moderate"
}]
}
</script>
eagerness controls when the browser acts. immediate starts as soon as the rule is seen. On desktop, eager waits for a pointer to rest on a link for about 10 ms, moderate for about 200 ms or a press, and conservative for the press itself; on mobile Chrome uses viewport heuristics for eager and moderate.
Chrome also caps how many it keeps: up to 10 prerenders for immediate rules, and only the 2 most recent for the other levels. So a mouse sweeping across a grid does not start forty page loads. The Prefetching lesson covers the full syntax and limits.
- Chromium only. Other browsers ignore the script, so it is pure progressive enhancement.
- Wasted work. An unused prerender costs the user bytes and your server a page build. Start with
moderateand measure the hit rate. - Side effects. Never prerender URLs that change something on
GET, like/logout. The server sees aSec-Purposeheader withprerenderand can decline. - Analytics. A prerendered page runs before the user sees it. Count the view on the
prerenderingchangeevent, or whendocument.prerenderingis false.
Check your understanding · Exercise 5
A news site's Back button is slow: field data shows its article pages are rarely restored from the bfcache, in Chrome and in Firefox. Which changes could fix it? Select all that apply.
The cost of a document that never ends
An SPA's best quality, a document that lives for the whole session, is also its biggest risk. Anything that should have been cleaned up on a click is not. In a shop where sessions last five minutes, nobody notices. In an email client, a trading screen, or a support tool that stays open for a full working day, small mistakes add up.
Memory grows with every route change
Take a support dashboard. An agent opens about 40 tickets an hour for 8 hours: 320 route changes. Suppose each ticket view leaves behind 1.5 MB: a chart that was not destroyed, a resize listener that still points to the old view, data in a cache with no size limit.
After a day that is about 480 MB on top of the normal 120 MB. Typing lags, garbage collection pauses grow, and on a low-memory laptop the tab may crash.
The same leak in an MPA is harmless. Each ticket is a new document, so the leaked 1.5 MB is freed when the agent moves on. The heap after the 320th ticket is the same as after the first. This is why some teams keep heavy tools as separate pages even inside an SPA product.
The usual sources are the same in every framework (the Memory in long-lived apps lesson covers each in depth):
- Listeners and timers on
window,document, or a socket, added on mount and never removed. - Detached DOM: nodes removed from the page but still referenced from JavaScript.
- Unbounded caches that keep every response the user ever saw. Give them a size or age limit; TanStack Query drops unused queries after
gcTime. - Global stores that collect per-page data and never drop it.
Old code in a new world
A tab opened on Monday may still run Monday's code on Thursday. Meanwhile the team shipped three deploys. This version skew shows up in three ways: the old code asks for route chunks that no longer exist, it calls API fields that were renamed, and it misses bug fixes. MPAs avoid all three, because every click loads the current HTML.
- Keep old hashed bundles on the CDN for days after a deploy, so old tabs can still load their chunks.
- Keep APIs backward compatible for at least one release.
- Ship a version check: when the server reports a newer build than the one running, the router does a full page load on the next navigation instead of a client one. The user moves to the new code on their next click, with no interruption.
Stale data and sessions
Data and sessions go stale too. A view may show data cached an hour ago, so refetch on focus and keep cache lifetimes short for things that change. A request after a long idle period should handle a 401 by refreshing the token or asking the user to sign in, without losing what they typed.
Check your understanding · Exercise 6
An internal SPA gets slower through the day and users fix it by reloading. Which steps address the cause rather than the symptom? Select all that apply.
Accessibility and performance on navigation
Navigation is where the two models differ most for users of assistive technology, and where they are measured most differently by performance tools. Both are easy to overlook in an interview and both make a strong answer stand out.
What a screen reader user hears
On a document navigation, the browser loads a new page, the screen reader announces its title, for example "Trail Runner 3, Shoe Shop", and reading starts from the top. The user knows the click worked and where they are.
On a same-document navigation, nothing tells the screen reader that anything happened. Focus stays on the clicked link, or falls back to the body if the link is gone. The user hears nothing and has to explore to find out whether the page changed. A keyboard user has a similar problem: the next Tab starts wherever focus was left, perhaps deep in the old sidebar.
document.title, focus the main heading, announce if focus should not move.The fix is a small routine that runs after every client navigation, covered in depth in the Focus management lesson:
- Set
document.titleto the new page's title. - Move focus to the new view's main heading (give it
tabindex="-1"so it can take focus), or to a skip target at the top of the main content. - If moving focus is not right, for example on a filter change, announce the change through a polite live region such as "24 results".
- On Back, restore focus to the element that opened the view when you can, such as the product card the user came from.
Next.js, Gatsby, and Astro's client router include a built-in route announcer; React Router and Vue Router leave it to you. Either way, test it with a real screen reader. This is one of the most common accessibility failures in SPAs, and an MPA never has it.
How navigations are measured
Core Web Vitals (see the Core Web Vitals lesson) were designed around documents. LCP, the time until the largest content element is painted, is measured once per document navigation. That has three effects worth knowing.
- SPA route changes are mostly invisible to LCP. A client navigation does not start a new LCP measurement. Chrome has been experimenting with measuring these soft navigations; until that is standard, time route changes yourself with
performance.mark(). - bfcache restores and prerendered pages count, and are fast. Chrome's field data counts them as page loads with an LCP close to zero, which lifts an MPA's scores.
- INP sees every click in both models. INP, the delay from an interaction to the next paint, includes the click that starts a client navigation. A route change that renders for 300 ms before painting gives a poor INP. Show feedback first, then render.
Check your understanding · Exercise 7
Hybrids, and when each model wins
Very few real products are pure SPAs or pure MPAs any more. Most sit somewhere between, choosing per page or per feature. Knowing the common hybrids lets you pick the right point instead of the two ends.
The common shapes
- MPA with islands. Server HTML per page, with a few interactive components hydrated on their own (made interactive by running their script in the browser). Navigation stays a document navigation. Astro is built around this (see Islands and partial hydration).
- HTML over the wire. The server renders HTML, and a small library such as Turbo or htmx catches clicks, fetches the next page's HTML, and swaps the body, without a client-side app.
- SPA shell with server rendering. The first request returns full HTML, then the app hydrates and handles later clicks. Next.js, Nuxt, SvelteKit, and React Router in framework mode work this way. You get a fast first load and SPA navigation, plus hydration cost and the long-lived document's risks.
- Several SPAs behind one domain. Marketing pages as an MPA, checkout as one small SPA, a seller dashboard as another. Crossing between them is a document navigation, which also resets memory and code version.
What we pick: Content and commerce sites with lots of first visits from search: an MPA with prerendering and view transitions, or islands, with a small SPA only where state must persist (a checkout or a player). Long-session tools like mail, docs, design, and dashboards: an SPA shell, server-rendered if first load or sharing matters, with real leak and version-skew discipline.
What well-known products do
Wikipedia is a classic MPA: short visits from search, almost all content, pages that must work on any device. Gmail, Figma, and Linear are SPAs: people keep them open for hours and move between many views of the same data. GitHub has long sat in between, mixing server-rendered pages, partial page swaps in the style of Turbo, and React for its most interactive views. Many large stores keep product and search pages as documents and give checkout its own small client app.
When an MPA wins
- Short visits from search. A news article, a product page, a docs page: the first load is the experience.
- Mostly content. Little shared state between pages means little to lose on each click.
- Low-end phones or bad networks. Less JavaScript means faster pages and fewer failures.
- Small team or long-lived site. No router, no focus code, no version skew, no leak hunting.
- Resilience matters. Government services, banking forms, and checkouts that must work when a script fails.
When an SPA wins
- Sessions last hours across many views of the same data: email, chat, project and design tools.
- Something must keep running across views: a call, a player, a live document, an editor with undo history.
- Offline use is required, so the app must start without the network.
- The UI is one workspace more than a set of pages, like Figma's canvas or a spreadsheet.
Think first
A podcast website is mostly content pages found through search, but listeners want the episode to keep playing while they browse other episodes. What would you build?
The content is MPA-shaped: short visits from search, mostly text, many first loads. The player is the one thing that must survive navigation, which a document navigation cannot do.
Two good answers. One: an SPA shell with server rendering, so every episode page is real HTML for search and the player lives in a layout that never unmounts. Two: an MPA with a small client router (Turbo, or Astro's client router with a persisted player island) that swaps page bodies but keeps the player element. Either way, keep the pages light and bfcache-friendly, and manage focus on each swap.
Using it in a system design interview
The navigation model comes up early in most frontend design rounds, usually right after requirements, when you sketch the architecture. It is a short decision, but the reasoning behind it shows whether you think about users or about frameworks.
When to bring it up
- When you choose a rendering strategy for the product: state the navigation model in the same breath (see Choosing a rendering strategy).
- When the product has something that must persist across pages: a player, a call, a chat dock, an editor.
- When the interviewer asks about performance, Back button behavior, memory, offline use, or accessibility.
What to say, by level
A mid-level answer picks a model with a reason tied to the product: "This is a dashboard that stays open all day and keeps a websocket and filters across views, so an SPA, server-rendered for the first route." Then it names the costs it takes on: focus management on route change, leak discipline, and a version check.
A senior answer adds the browser features that change the trade-off, and chooses per area: "The marketing site and help center are MPAs with Speculation Rules and view transitions. The app behind login is an SPA shell. Crossing between them is a document navigation, which also resets memory and picks up new deploys." It mentions how success is measured: field LCP for first loads, custom route timings for client navigations, bfcache hit rate, and heap size after long sessions.
A staff answer talks about the organization too: separate SPAs per team behind one domain so teams deploy alone, shared design tokens so the seams do not show, and a cross-document transition to hide the switch between them.
What to draw
Draw the route map with a line between the parts that are document navigations and the parts that are client navigations. Mark what persists in each area, where the server renders, and where the data cache lives. That one picture answers most follow-up questions before they are asked.
Check your understanding · Exercise 8
You are designing a hotel booking site: search results and hotel pages from search engines, then a multi-step booking flow. Which proposals are well reasoned? Select all that apply.
Summary
- The real question is who handles the click: the browser (document navigation, MPA) or your JavaScript (same-document navigation, SPA). Everything tied to the document resets in one and persists in the other.
- An MPA gets history, scroll, focus, memory reset, progressive enhancement, and fresh deploys for free. An SPA gets persistent state, fast later clicks, custom transitions, and easy offline.
- The bfcache makes Back and Forward instant for eligible pages; remove
unloadlisteners, avoid needlessno-store, and close connections inpagehide. - Speculation Rules prerendering makes forward clicks instant in Chromium, and cross-document view transitions animate MPA navigations in Chrome and Safari. Both are safe progressive enhancements.
- Long-lived SPAs must manage memory, version skew after deploys, stale data, and focus on every route change.
- Most real products are hybrids: MPA with islands, HTML over the wire, an SPA shell with server rendering, or several SPAs behind one domain.
- MPAs win for short, content-heavy, search-driven visits on weak devices; SPAs win for long sessions, persistent features, offline use, and workspace-like UIs.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is the difference between an SPA and an MPA, from the browser's point of view?
In an MPA, every link click is a document navigation: the browser requests new HTML, unloads or freezes the old document, and builds a new one. History, scroll, focus, and memory reset are handled by the browser.
In an SPA, one document lives for the whole session. A router intercepts clicks, updates the URL with pushState or the Navigation API, and renders new views in place. State persists across views, but the app must handle title, focus, scroll, and cleanup itself.
Question 2
How can you make an MPA feel as fast as an SPA?
Make Back and Forward instant by keeping pages eligible for the back/forward cache: no unload listeners, no needless Cache-Control: no-store, and connections closed in pagehide and reopened in pageshow.
Make forward clicks instant in Chromium with Speculation Rules: prerender likely next pages with moderate eagerness, avoiding URLs with side effects. Add cross-document view transitions with @view-transition so page changes animate. Keep pages light and server responses fast for the browsers without these features.
Question 3
What goes wrong in SPAs that stay open all day, and how do you prevent it?
Memory grows from listeners, timers, detached DOM, and unbounded caches that each route change leaves behind. Find leaks with heap snapshots before and after repeating a flow; bound caches; clean up on unmount.
Code goes stale after deploys, causing missing chunks and API mismatches. Keep old chunks on the CDN, keep APIs backward compatible, and switch to a full page load on the next navigation when a new build is detected. Data and sessions go stale too: refetch on focus and handle expired tokens without losing input.
Question 4
What accessibility work does client-side routing require?
After each route change, update document.title, move focus to the new main heading or a skip target, and announce changes through a live region when focus should not move. On Back, restore focus near where the user was.
Use real links with real href values so new tabs, copying, and screen reader link lists work. Test with a screen reader, because a silent route change is one of the most common SPA accessibility failures.
Question 5
A content site with a persistent audio player: SPA or MPA?
The content suits an MPA, but the player must survive navigation, which a document navigation cannot do. Use either an SPA shell with server rendering where the player lives in a layout that never unmounts, or an MPA with a small client router that swaps page bodies and keeps the player.
In both cases keep every page as real HTML for search, keep pages light, and manage focus on each client navigation.
Practice it
These design problems lean on single-page vs multi-page apps.
References
- Back/forward cache (web.dev) How the bfcache works, what blocks it, and how to test it.
- Back/forward cache for Cache-Control: no-store pages (Chrome) When Chrome now caches
no-storepages, and when it evicts them. - Prerender pages in Chrome for instant page navigations Speculation Rules prerendering, eagerness, and caveats.
- Smooth transitions with the View Transition API Same-document and cross-document view transitions.
- View Transition API (MDN) Reference for the pseudo-elements, events, and CSS at-rule.
- Navigation API (MDN) The
navigateevent andintercept()for client-side routing. - History API (MDN)
pushState,replaceState, andpopstate.
Read next
- Client-side renderingThe server sends an almost empty HTML page plus a JavaScript bundle, and the browser builds the whole page itself: easy to host and great behind a login, but slow to first content and weak for search.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.
- Choosing a rendering strategyHow to pick the rendering strategy for each page by asking who sees it, how often it changes, how interactive it is, and whether strangers must find it, then mixing strategies route by route in one app instead of choosing one for everything.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.