I’ve been thinking a lot recently about Single-Page Apps (SPAs) and Multi-Page Apps (MPAs). I’ve been thinking about how MPAs have improved over the years, and where SPAs still have an edge. I’ve been thinking about how complexity creeps into software, and why a developer may choose a more complex but powerful technology at the expense of a simpler but less capable technology.
I think this core dilemma – complexity vs simplicity, capability vs maintainability – is at the heart of a lot of the debates about web app architecture. Unfortunately, these debates are so often tied up in other factors (a kind of web dev culture war, Twitter-stoked conflicts, maybe even a generational gap) that it can be hard to see clearly what the debate is even about.
At the risk of grossly oversimplifying things, I propose that the core of the debate can be summed up by these truisms:
- The best SPA is better than the best MPA.
- The average SPA is worse than the average MPA.
The first statement should be clear to most seasoned web developers. Show me an MPA, and I can show you how to make it better with JavaScript. Added too much JavaScript? I can show you some clever ways to minimize, defer, and multi-thread that JavaScript. Ran into some bugs, because now you’ve deviated from the browser’s built-in behavior? There are always ways to fix it! You’ve got JavaScript.
Whereas with an MPA, you are delegating some responsibility to the browser. Want to animate navigations between pages? You can’t (yet). Want to avoid the flash of white? You can’t, until Chrome fixes it (and it’s not perfect yet). Want to avoid re-rendering the whole page, when there’s only a small subset that actually needs to change? You can’t; it’s a “full page refresh.”
My second truism may be more controversial than the first. But I think time and experience have shown that, whatever the promises of SPAs, the reality has been less convincing. It’s not hard to find examples of poorly-built SPAs that score badly on a variety of metrics (performance, accessibility, reliability), and which could have been built better and more cheaply as a bog-standard MPA.
Example: subsequent navigations
To illustrate, let’s consider one of the main value propositions of an SPA: making subsequent navigations faster.
Rich Harris recently offered an example of using the SvelteKit website (SPA) compared to the Astro website (MPA), showing that page navigations on the Svelte site were faster.
Now, to be clear, this is a bit of an unfair comparison: the Svelte site is preloading content when you hover over links, so there’s no network call by the time you click. (Nice optimization!) Whereas the Astro site is not using a Service Worker or other offlining – if you throttle to 3G, it’s even slower relative to the Svelte site.
But I totally believe Rich is right! Even with a Service Worker, Astro would have a hard time beating SvelteKit. The amount of DOM being updated here is small and static, and doing the minimal updates in JavaScript should be faster than asking the browser to re-render the full HTML. It’s hard to beat element.innerHTML = '...'
.
However, in many ways this site represents the ideal conditions for an SPA navigation: it’s small, it’s lightweight, it’s built by the kind of experts who build their own JavaScript framework, and those experts are also keen to get performance right – since this website is, in part, a showcase for the framework they’re offering. What about real-world websites that aren’t built by JavaScript framework authors?
Anthony Ricaud recently gave a talk (in French – apologies to non-Francophones) where he analyzed the performance of real-world SPAs. In the talk, he asks: What if these sites used standard MPA navigations?
To answer this, he built a proxy that strips the site of its first-party JavaScript (leaving the kinds of ads and trackers that, sadly, many teams are not allowed to forgo), as well as another version of the proxy that doesn’t strip any JavaScript. Then, he scripted WebPageTest to click an internal link, measuring the load times for both versions (on throttled 4G).
So which was faster? Well, out of the three sites he tested, on both mobile (Moto G4) and desktop, the MPA was either just as fast or faster, every time. In some cases, the WebPageTest filmstrips even showed that the MPA version was faster by several seconds. (Note again: these are subsequent navigations.)
On top of that, the MPA sites gave immediate feedback to the user when clicking – showing a loading indicator in the browser chrome. Whereas some of the SPAs didn’t even manage to show a “skeleton” screen before the MPA had already finished loading.
Now, I don’t think this experiment is perfect. As Anthony admits, removing inline <script>
s removes some third-party JavaScript as well (the kind that injects itself into the DOM). Also, removing first-party JavaScript removes some non-SPA-related JavaScript that you’d need to make the site interactive, and removing any render-blocking inline <script>
s would inherently improve the visual completeness time.
Even with a perfect experiment, there are a lot of variables that could change the outcome for other sites:
- How fast is the SSR?
- Is the HTML streamed?
- How much of the DOM needs to be updated?
- Is a network request required at all?
- What JavaScript framework is being used?
- How fast is the client CPU?
- Etc.
Still, it’s pretty gobsmacking that JavaScript was slowing these sites down, even in the one case (subsequent navigations) where JavaScript should be making things faster.
Exhausted developers and clever developers
Now, let’s return to my truisms from the start of the post:
- The best SPA is better than the best MPA.
- The average SPA is worse than the average MPA.
The cause of so much debate, I think, is that two groups of developers may look at this situation, agree on the facts on the ground, but come to two different conclusions:
“The average SPA sucks? Well okay, I should stop building SPAs then. Problem solved.” – Exhausted developer
“The average SPA sucks? That’s just because people haven’t tried hard enough! I can think of 10 ways to fix it.” – Clever developer
Let’s call these two archetypes the exhausted developer and the clever developer.
The exhausted developer has had enough with managing the complexity of “modern” web sites and web applications. Too many build tools, too many code paths, too much to think about and maintain. They have JavaScript fatigue. Throw it all away and simplify!
The clever developer is similarly frustrated by the state of modern web development. But they also deeply understand how the web works. So when a tool breaks or a framework does something in a sub-optimal way, it irks them, because they can think of a better way. Why can’t a framework or a tool fix this problem? So they set out to find a new tool, or to build it themselves.
The thing is, I think both of these perspectives are right. Clever developers can always improve upon the status quo. Exhausted developers can always save time and effort by simplifying. And one group can even help the other: for instance, maybe Parcel is approachable for those exhausted by Webpack, but a clever developer had to go and build Parcel first.
Conclusion
The disparity between the best and the average SPA has been around since the birth of SPAs. In the mid-2000s, people wanted to build SPAs because they saw how amazing GMail was. What they didn’t consider is that Google had a crack team of experts monitoring every possible problem with SPAs, right down to esoteric topics like memory leaks. (Do you have a team like that?)
Ever since then, JavaScript framework and tooling authors have been trying to democratize SPA tooling, bringing us the kinds of optimizations previously only available to the Googles and the Facebooks of the world. Their intentions have been admirable (I would put my own fuite
on that pile), but I think it’s fair to say the results have been mixed.
An expert developer can stand up on a conference stage and show off the amazing scores for their site (perfect performance! perfect accessibility! perfect SEO!), and then an excited conference-goer returns to their team, convinces them to use the same tooling, and two years later they’ve built a monstrosity. When this happens enough times, the same conference-goer may start to distrust the next dazzling demo they see.
And yet… the web dev community marches forward. Today I can grab any number of “starter” app toolkits and build something that comes out-of-the-box with code-splitting, Service Workers, tree-shaking, a thousand different little micro-optimizations that I don’t even have to know the names of, because someone else has already thought of it and gift-wrapped it for me. That is a miracle, and we should be grateful for it.
Given enough innovation in this space, it is possible that, someday, the average SPA could be pretty great. If it came batteries-included with proper scroll, focus, and screen reader announcements, tooling to identify performance problems (including memory leaks), progressive DOM rendering (e.g. Jake Archibald’s hack), and a bunch of other optimizations, it’s possible that developers would fall into the “pit of success” and consistently make SPAs that outclass the equivalent MPA. I remain skeptical that we’ll get there, and even the best SPA would still have problems (complexity, performance on slow clients, etc.), but I can’t fault people for trying.
At the same time, browsers never stop taking the lessons from userland and upstreaming them into the browser itself, giving us more lines of code we can potentially delete. This is why it’s important to periodically re-evaluate the assumptions baked into our tooling.
Today, I think the core dilemma between SPAs and MPAs remains unresolved, and will maybe never be resolved. Both SPAs and MPAs have their strengths and weaknesses, and the right tool for the job will vary with the size and skills of the team and the product they’re trying to build. It will also vary over time, as browsers evolve. The important thing, I think, is to remain open-minded, skeptical, and analytical, and to accept that everything in software development has tradeoffs, and none of those tradeoffs are set in stone.
Posted by Anthony on June 27, 2022 at 12:43 PM
Fun thing: soon, SPAs will be able to do this with the help of the Navigation API. https://github.com/WICG/navigation-api
Great article, thanks!
Posted by Nolan Lawson on June 27, 2022 at 1:35 PM
Great point! This API also has some fixes for focus and scroll issues. I think once this is available across browsers, it’ll really raise the bar for the average SPA.
Posted by Alexandre Dieulot on June 27, 2022 at 1:51 PM
As someone capable of making a reliable SPA library (InstantClick in 2014, unmaintained) I’ve come to the conclusion that it’s less about being a clever developer and more about being good at usability and having an eye for detail.
I surmise that the vast majority of front-end/full-stack developers fail deeply in that department. See for instance the early bragging about React being used by Instagram, while Instagram couldn’t (and IIRC still can’t) remember the previous page’s scroll position. See also the many, many, many SPAs with broken previous-page-scroll-position that don’t cumulatively sound off the alarm that something is deeply wrong with how mainstream SPAs are being used.
From that position I don’t see the problem of debates/communication being hopeless due to the current culture or social media, I see it as hopeless due to most developers caring very little about design/usability.
Posted by Oenonono on June 27, 2022 at 2:03 PM
The hybrids are where it’s at. (Which is what SvelteKit does. Depending on how you configure it.)
Posted by Weston Thayer on June 27, 2022 at 2:13 PM
I’ve been grappling with truism #1 from an accessibility perspective more and more lately. Most of the debate I’ve seen revolves around whether a SPA solution is more or less usable/ideal compared to its MPA equivalent, but after digging deeper, I consider them blocked. Two examples:
SPA navigation announcements seem to most commonly use ARIA live regions in an attempt to replicate the MPA UX (Next.js ships with it by default). But I don’t think many are aware screen readers don’t send live region events to refreshable braille displays (by design [1]). In an MPA nav, browsers fire a11y event types that we don’t have access to in JS, and those are sent to braille displays by screen readers. The SPA live region technique blocks braille display users, while an MPA works fine, but the industry views it as a workable solution. Google Docs found a different technique, but requires users to self-identify, which is a pretty terrible privacy tradeoff [2]. I don’t think there is an acceptable solution for SPAs here until https://github.com/WICG/navigation-api ships.
Keyboard focus a11y got a big boost from bf cache. Not only is focus on interactive/tabindex=-1 elements restored, but the SFNSP [3] is too, which means keyboarding an MPA is actually pretty nice now (it used to constantly restart you at the top of the document). Since SPAs can’t use bf cache and there’s no SFNSP API [4] (nor acceptable hacks to set it), they just can’t compete. Maybe this isn’t a ‘blocker’, but lord losing your focus position is frustrating, especially when it works on some sites but not others. (To be fair, MPAs aren’t perfect [5] either).
For years I’ve had the attitude of the clever developer. I’ve been amazed by the ‘have your cake & eat it too’ techniques invented over the years in performance/reliability. But I worry that we’re too quick to assume accessibility is the same way — JS can fix it if we try hard enough. JS doesn’t have many raw a11y materials to work with. AOM is on ice due to privacy concerns [6]. ARIA can only fire 1 of the many UIA event types [7]. Many ARIA roles are unsupported on mobile [8].
I believe all these things can be fixed with platform support, but browser’s a11y crank turns way slower than its others. Maybe raising awareness of how even the best SPA falls woefully short of an MPA when it comes to accessibility will help.
[1] https://github.com/nvaccess/nvda/issues/7756
[2] https://support.google.com/docs/answer/6057417
[3] https://html.spec.whatwg.org/multipage/interaction.html#sequential-focus-navigation-starting-point
[4] https://github.com/whatwg/html/issues/5326#issuecomment-1150090942
[5] https://github.com/whatwg/html/issues/7397
[6] https://github.com/w3ctag/design-principles/issues/293
[7] https://docs.microsoft.com/en-us/dotnet/framework/ui-automation/ui-automation-events-overview
[8] https://github.com/w3c/aria-practices/issues/8
Posted by Nolan Lawson on June 27, 2022 at 5:19 PM
Thank you for your very detailed comment. It definitely seems like the Navigation API will really unlock a lot of accessibility hurdles with SPAs. I’m waiting to see how it pans out, but I’m really hopeful that browsers implement it, and it provides a better baseline than the current “every framework independently figures out how scroll, focus, and navigation announcements work.”
For restoring focus, I actually filed a bug on Chromium about this a year ago. It seems that now it’s fixed for same-origin navigations (perhaps thanks to BF cache), but not cross-origin navigations apparently (although it works correctly in Firefox and WebKit). Hopefully Chromium eventually irons this out.
For AOM, I have a lot more to say about it which probably requires its own blog post, but there are many parts of it, and it does seem to be making some recent progress (e.g. ARIA element reflection landing in WebKit). I imagine it must be frustrating for an accessibility advocate, though, to see so little apparent care from both web app authors and browser vendors on this topic.
Posted by Weston Thayer on June 27, 2022 at 8:19 PM
The Navigation API should put a bow on navigation announcements (and focus for switch control / full keyboard access to boot), unfortunately it won’t help at all with focus. Frameworks don’t have great options there. They can’t cache SFNSP because they don’t know where it is, much less restore it. Even trying to cache/restore interactive elements is a pretty dang hard problem. You could give each a unique id and cache that, or compute a unique CSS selector. We really need a bf cache API that SPAs can toss DOM fragments into to freeze/thaw state and a SFNSP API in addition to the Navigation API.
I guess the core of my frustration is: imagine you have a GMail-sized budget and a great reason to create a SPA. You can make it more performant and more reliable than a MPA, but you can’t make it more accessible. I feel like raising awareness is a key part of changing that.
Looking forward to your thoughts on AOM someday :)
Posted by Nolan Lawson on June 28, 2022 at 6:55 AM
Ah you’re right, I just re-read the Navigation API README on focus, and yes, back navigations still need to be handled manually. I wonder, though, if this is something that could be added to the API later, e.g. through an ID system.
The API does at least handle normal forward navigations (e.g. resetting focus to the
<body>
), so at least there’s that.Posted by Weston Thayer on June 28, 2022 at 1:47 PM
Yeah the focus to body on history push is helpful, IIRC it fires the same a11y events as an MPA nav, which you won’t get by calling document.body.focus().
Unfortunately the id caching idea was ruled out as too brittle: https://github.com/WICG/navigation-api/issues/190#:~:text=This%20seems%20really-,brittle,-and%20unpredictable.
Posted by Weston Thayer on June 27, 2022 at 2:21 PM
Really enjoyed this post Nolan! I tried leaving a longer comment re: accessibility, but it’s not showing up. Spam filter got it maybe? Pasted here: https://gist.github.com/WestonThayer/861f518162542f6ebd8ba2211de40355
Posted by Nolan Lawson on June 27, 2022 at 5:05 PM
I’ve unspammed your other comment. :)
Posted by Weston Thayer on June 27, 2022 at 7:17 PM
:D thanks
Posted by Marc on June 29, 2022 at 4:25 AM
Having developed web sites and applications since 1996 I consider myself old school. Despite advance, an SPA should never be the default, go-to choice for a website. For a webapp sure but not for a human navigating pages of content. The genius of HTML, links, history have yet to be surpassed. They work OOTB and are effective and efficient for most cases.
Agree heartily with this article 👍
Posted by judgejeffreys on July 2, 2022 at 12:09 AM
Not a word about budget? For a charity who have better things to do with their funds than paying me for build and long-term maintainenance of a site, I (and every honest web developer) must constantly ask ourselves whether the benefits of an SPA, or any use of a complex technology such as a JS framework, is really serving the client and the community with a cost-effectivene solution. John_B
Posted by Eimantas on August 30, 2022 at 10:04 AM
Great article! Put’s so many ideas on black and white, how I felt about this whole React trend going on these days!
Bookmarking this to show to the clients and people who make decisions!
We had SSR on day 1 when browser was invented and we’ve decided to scratch that, so we can add more bells and whistles.
Talking about JS tooling and improvements and introducing some “saviour” api to solve most anoying issue we shouldn’t forget that “native” web development is moving forward too.
I hope page transitions api, once widely adopted will kill this SPA madness
https://developer.chrome.com/blog/shared-element-transitions-for-spas/
Posted by Oliver on August 17, 2023 at 4:07 AM
“Want to avoid the flash of white? You can’t, until Chrome fixes it” I don’t understand what you mean here. You link to a post that says Chrome has fixed the flash of white. Safari also does paint holding.
Posted by Nolan Lawson on August 17, 2023 at 9:38 PM
I linked to a comment where someone said the flash-of-white wasn’t fully resolved in Chrome. I dunno, maybe it is now.
Posted by What's a Single-Page App? | jakelazaroff.com on November 24, 2024 at 11:45 PM
[…] couple years ago, Nolan Lawson attempted to bridge the two camps SPAs: theory versus practice I’ve been thinking a lot recently about Single-Page Apps (SPAs) and […]