cf.completefrontend
My progress
FUNDAMENTALS · MODULE 7

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.

  1. 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.
  2. 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.
  3. 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:

  1. The browser fires beforeunload, then requests the new URL. The old page stays on screen while it waits.
  2. 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.
  3. The browser parses the new HTML, loads its CSS and scripts (shared files usually from the HTTP cache), and paints.
  4. 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.

The same click from /search to /product in two models, side by side. Multi-page: the browser fetches full HTML for /product and builds a whole new document; every part of the page is rebuilt and the old document is freed or frozen in the back/forward cache. Single-page: the router fetches 3 KB of JSON and swaps only the main area; the header and sidebar stay mounted and keep their state.multi-page: new document/searchHTML, 30 KB/productall rebuilt, heap resetsingle-page: same document/searchJSON, 3 KB/productmain swapped, rest kept
In the MPA every part of the new page is new, including memory, focus, and scroll. In the SPA only the route's area changes, so state survives, and so does anything that was never cleaned up.

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.

  1. The router matches the URL against its route table.
  2. It downloads the route's code chunk if needed (see Code splitting) and fetches missing data, usually as JSON.
  3. It renders the new view in place. The header, sidebar, and anything outside the route stay mounted.
  4. 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.

A sequence diagram of one client-side navigation between the user, the router, the History API, and the server API. The user clicks a link to /p/42. The router calls preventDefault to stop the document navigation, then pushState with /p/42, which changes the URL and adds a history entry without loading anything. The router requests /api/p/42 and gets 3 KB of JSON back, renders the new view, then sets the page title and moves focus. Once the data arrives, rendering and the rest take about 40 ms.UserRouterHistoryAPIclick /p/42preventDefault()pushState /p/42GET /api/p/42JSON, 3 KBrender, 40 mstitle + focus
The router replaces the browser's navigation step by step. The last arrow, title and focus, is the one most often forgotten: a document navigation did it for free.

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 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.

ts
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
    },
  });
});
A tiny router on the Navigation API. The browser handles the URL, history, and aborts.

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", a download attribute, 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 /admin served 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.

A matrix of nine qualities against three models: a plain MPA, a modern MPA with bfcache, prerendering and view transitions, and an SPA. Fast first view: yes, yes, with server rendering. Memory reset per page: yes, yes, with work. Focus, history and scroll: yes, yes, with work. Instant Back: no, yes through bfcache, yes. Instant forward click: no, in Chromium, yes. Animated page change: no, yes except in Firefox, yes. State survives a click: no, no, yes. Works without JavaScript: yes, yes, no. New deploy on the next click: yes, yes, with work.plain MPAmodern MPASPAfast first viewyesyeswith SSRmemory reset per pageyesyeswith workfocus, history, scrollyesyeswith workinstant Backnobfcacheyesinstant forward clicknoChromiumyesanimated page changenono Firefoxyesstate survives a clicknonoyesworks without JSyesyesnonew deploy next clickyesyeswith work
The modern MPA closes most of the gap with browser features alone. The row it cannot close is state that survives a click, which is the SPA's real reason to exist.
ConcernMPASPA
First visit from searchFast: full HTML, small scriptsSlow unless server-rendered first
Later click, code and data cached300 to 1,200 ms, or near 0 with prerender20 to 80 ms
Back and Forwardbfcache restore in a few msRouter re-renders from its cache
Memory after 8 hoursSame as after 1 minuteGrows with every leak
Audio, video, chat across pagesRestarts on each pageKeeps running
Focus and announcementsBrowser does itApp must do it
Works if JavaScript failsMostly yesUsually no
New deploy reaches the userNext clickNext 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.

Model
Turn on

First load of Home: 1100 ms, first load. Tab memory about 60 MB. Waiting after the first load: 0 ms in total.

Time per step for the MPA so far: Home 1100 ms.Home1100 msSearchProductCartCheckoutBack: CartBack: Product
Illustrative numbers from a rough model of a mid-range phone on 4G, not measurements; bars are scaled to 2.4 s. In the MPA, prerender shrinks forward clicks and the bfcache shrinks Backs; view transitions change how a step looks, not how long it takes.
What survives the flow so far
Thing on the pageSurvives?Why
Shoe size picked on Productnolost on Back: page rebuilt, scripts start over
Scroll on Backyesbrowser restores it
Mini video playernorestarts on every page
Cart count in headeryesrendered 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-photo on 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 root by default, which is where the cross-fade comes from.
A timeline of a cross-document view transition after a click at 0 ms. From 0 to 300 ms the user still sees the old page, frozen, while the browser fetches and parses the new page; pageswap fires on the old page at about 280 ms. At 300 ms the browser snapshots the old page and the new page renders its first frame, with pagereveal firing on the new page. From 340 to 590 ms the browser animates from the old snapshot to the live new page on a layer above the page. After 590 ms the new page is shown alone.what the user seesold page, frozenanimate, 250 msnewwhat the browser doesfetch, parse new pageanimate to newpageswappagereveal0300 ms590 ms
The animation starts only when the new page can paint, so a slow server is still slow. Prerendering removes most of the first 300 ms; @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.

css
@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;
  }
}
Both pages include this. The product photo on the list page and on the detail page share a name.

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-name at 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.

A state machine of a page when the user leaves it. From Active, leaving an eligible page fires pagehide with persisted true and moves it to Frozen in the bfcache, paused. Pressing Back fires pageshow with persisted true and returns it to Active in a few milliseconds. From Frozen, a time limit or low memory moves it to Evicted. Leaving a page that is not eligible moves it straight to Destroyed. From Evicted or Destroyed, Back means a full page load.Activeuser on pageFrozenin bfcache, pausedleave: pagehideBack: pageshowDestroyedheap freedleave, not eligibleEvictedmemory freedtime limit, low memoryBack now meansa full page load
Only the top loop is instant. Every blocker you remove moves more Back clicks from the bottom row to the top one; listen for 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 unload listener. 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 removing unload in 2026. Use pagehide or visibilitychange instead.
  • Cache-Control: no-store on the HTML. For years this kept pages out of the bfcache. In 2025 Chrome rolled out a change that lets most no-store pages 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 use no-store only 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 pagehide and reopen them in pageshow.
  • window.opener links and long IndexedDB transactions. Use rel="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.

The product page has

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.

html
<script type="speculationrules">
{
  "prerender": [{
    "where": {
      "and": [
        { "href_matches": "/products/*" },
        { "not": { "selector_matches": ".no-prerender" } }
      ]
    },
    "eagerness": "moderate"
  }]
}
</script>
Prerender any product page once the user shows intent: on desktop, a hover of about 200 ms or a press.

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 moderate and measure the hit rate.
  • Side effects. Never prerender URLs that change something on GET, like /logout. The server sees a Sec-Purpose header with prerender and can decline.
  • Analytics. A prerendered page runs before the user sees it. Count the view on the prerenderingchange event, or when document.prerendering is 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.

A bar chart of tab memory for a support agent opening 40 tickets an hour. After 1, 2, 4 and 8 hours: an MPA page stays at about 70 MB each time; an SPA that cleans up stays at about 120 MB; an SPA that leaks 1.5 MB per ticket view starts from the same 120 MB and grows to 180, 240, 360 and 600 MB.MPA pageSPA, cleaned upSPA, leaking6000 MB1801 h2402 h3604 h6008 h
The MPA and the clean SPA stay flat; the leaking SPA climbs like a staircase. The same leaky code in an MPA page would stay flat too, because each new document starts fresh.

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.

Two wireframes of the checkout view right after a client-side route change. Without focus work: focus has fallen back to the body because the clicked button was removed, and the screen reader says nothing. With focus work: the title is set, focus is on the Checkout heading, and the screen reader says Checkout, heading level 1.without focus workCheckoutCheckoutfocus: bodyscreen reader says:nothingwith focus workCheckout, Shoe ShopCheckoutfocus: h1screen reader says:Checkout, heading 1
Same view, same pixels. The difference is three lines after each route change: set 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:

  1. Set document.title to the new page's title.
  2. 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.
  3. If moving focus is not right, for example on a filter change, announce the change through a polite live region such as "24 results".
  4. 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

After a client-side route change from the cart to checkout, which behaviors are correct? Select all that apply.

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.
A layered diagram of a hybrid shop. In the browser, two areas: MPA pages rendered as server HTML (Home, Search with filters, and Product with two islands), using prerender, bfcache and view transitions; and a small checkout SPA (Cart and payment Steps) with client routes and state kept between steps. Moving between the two areas is a full document navigation. Below, the server and edge tier: a CDN for HTML and assets, an HTML renderer that builds each page, and a JSON API for cart and payment that the SPA calls.MPA pages: server HTMLHomestaticSearchfiltersProduct2 islandsprerender, bfcache, view transitionsCheckout SPACartclientStepsstate keptclient routesServer and edgeCDNHTML and assetsHTML rendererbuilds each pageJSON APIcart, payment
Each area uses the model that fits it. The seam between them is a document navigation, which also resets memory and picks up new deploys.

MPA with modern browser features

Server-rendered HTML per page, little JavaScript

bfcache-eligible pages, Speculation Rules prerender for likely next pages

Cross-document view transitions for polish

Pros

  • Fast first loads, clean memory per page
  • Focus, history, and scroll work with no code
  • Works without JavaScript; every deploy reaches users on the next click

Cons

  • Nothing survives a click except what you store
  • Instant forward clicks only in Chromium
  • Rich shared UI across pages needs repeated hydration

MPA with islands or HTML over the wire

Server HTML per page

Interactive islands hydrate alone, or Turbo/htmx swaps page bodies

Pros

  • Most of the MPA's simplicity
  • Interactivity only where it is needed
  • Small bundles

Cons

  • State shared between islands needs extra plumbing
  • Body-swapping libraries reintroduce some SPA concerns, like focus and leaks

SPA shell with server rendering

Server renders the first route

Router takes over after hydration, fetching data or server component payloads per click

Pros

  • Good first load and SEO
  • Fast later clicks, persistent UI, shared transitions
  • One codebase for server and client

Cons

  • Hydration cost on every first load
  • Long-lived document: leaks, version skew, manual focus
  • More moving parts on the server

Client-only SPA

Static HTML shell, all rendering in the browser

Often behind a login

Pros

  • Simple hosting from a CDN
  • Full control, easy offline with an app shell
  • Fastest navigation once loaded

Cons

  • Slowest first load
  • Poor fit for search traffic
  • Every MPA freebie must be rebuilt

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.
A decision flowchart. First: must something keep running across pages, such as a player, a call, or an editor? If yes, an SPA shell that keeps it in a layout. If no: do users spend hours in one workspace? If yes, an SPA shell, server-rendered if pages are public. If no: are there rich widgets on many content pages? If yes, an MPA with islands. If no, a modern MPA with prerendering, bfcache and view transitions.Persistent feature?player, call, editoryesSPA shellkeep it in a layoutnoLong sessions?hours in one workspaceyesSPA shell + SSRSSR if pages are publicnoRich widgets?on many content pagesyesMPA with islandshydrate only widgetsnoModern MPAprerender, bfcache, transitions
Most content and commerce sites fall through to the bottom two boxes. Splitting a product into areas lets each area take its own path through this chart.

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?

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 unload listeners, avoid needless no-store, and close connections in pagehide.
  • 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?

Question 2

How can you make an MPA feel as fast as an SPA?

Question 3

What goes wrong in SPAs that stay open all day, and how do you prevent it?

Question 4

What accessibility work does client-side routing require?

Question 5

A content site with a persistent audio player: SPA or MPA?

Practice it

These design problems lean on single-page vs multi-page apps.

References

Read next