Routing: I’m not smart enough for a SPA
Tags: webdev, performance, a11y, javascript
Published: 2022-04-19 11:30:39

---
title: "Routing: I’m not smart enough for a SPA"
published: true
description: Retreating to classic navigation may be cowardice, but it’s INFORMED cowardice
tags: webdev, performance, accessibility, javascript
series: Streets
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qfd0dzv2j6c7ozes38za.png
---

In part 2, I glossed over a _lot_ when I wrote…

> I decided this called for an MPA. (aka a traditional web app. Site. Thang. Not-SPA. Whatever.)

Okay, but _why_ did I decide that? To demo the fastest possible Kroger.com, I should consider all options — why _not_ a Single-Page App?

## Alright, let’s try a SPA

While [React was ≈2× too big](https://dev.to/tigt/making-the-worlds-fastest-website-and-other-mistakes-56na#okay-20kb-now-what), surely I could fit a SPA of _some_ kind in my 20kB budget. I’ve seen it done: the [PROXX game’s initial load fits in 20kB](https://web.dev/load-faster-like-proxx/), and [it’s smooth on wimpier phones](https://web.dev/off-main-thread/#proxx:-an-omt-case-study) than the Poblano.

So, rough estimate off the top of my head…

<center>


Source                  | Parse size | gzip size
------------------------|-----------:|---------:
`preact` 10.6.6         | 10.2kB     | 4kB
`preact-router` 4.0.1   | 4.5kB      | 1.9kB
`react-redux` 7.2.6     | 15.4kB     | 5kB
`redux` 4.1.2           | 4.3kB      | 1.6kB
My components[^2]       | 5.9kB      | 2.1kB
**Total**               | 40.3kB     | ≈14.6kB

</center>

[^2]: Estimated by ruining my demo’s [split Marko components](https://markojs.com/docs/class-components/#split-components) and checking devtools on the homepage.

- The HTML is little else than `<script>`s, so its size is negligible.
- The remaining 5.4kB is plenty for some hand-tuned CSS.
- This total is pessimistic: [Bundlephobia’s estimate](https://bundlephobia.com/scan-results?packages=preact@10.6.6,preact-router@4.0.1,redux@4.1.2,react-redux@7.2.6) notes “Actual sizes might be smaller if only parts of the package are used or if packages share common dependencies.” 

Seems doable.

## …But is that _really_ all the code a SPA needs?

Some code I knew I’d need _eventually:_

- Translation between UI elements and API request/response formats
- [Updates in the `<head>`](https://github.com/preactjs/preact/issues/3285)
- Instead of fullsize image links, a lightbox script to avoid unloading the SPA
- Checking and [reloading outdated SPA versions](https://jakearchibald.com/2020/multiple-versions-same-time/)
- [Code splitting](https://developer.mozilla.org/en-US/docs/Glossary/Code_splitting) to fit in the 20kB first load (my components estimate was only for the homepage), which means dynamic resource-loading code
- Reimplementing `beforeunload` warnings for unsaved user input
- Analytics need extra code for SPAs. Consider two particularly relevant to our site:
  - [A “simple diagram” of SPAs in Adobe’s tracker](https://experienceleague.adobe.com/docs/analytics-learn/tutorials/implementation/spa-pages/using-best-practices-when-tracking-spa.html?lang=en#simple-diagram-of-working-with-spas-in-launch)
  - [Dynatrace’s “Capturing > Async web requests and SPAs”](https://www.dynatrace.com/support/help/how-to-use-dynatrace/real-user-monitoring/setup-and-configuration/web-applications/initial-configuration/configure-dynatrace-real-user-monitoring-to-capture-xhr-actions)

Buuuuuut I don’t have concrete numbers to back these up — I abandoned the SPA approach before grappling with them.

Why?

## It’s not always about the performance

I didn’t want to demo a toy site that was fast only because it ignored the responsibilities of the real site. To me, **those responsibilities (of grocery ecommerce) are**…

<dl>
<dt>🛡 <b>Security</b> even over accessibility
<dd>Downgrading HTTPS ciphers for old browsers isn’t worth letting credit cards be stolen.
<dd>You must protect data customers trust you with before you can use it.<br><br>

<dt>♿ <b>Accessibility</b> even over speed
<dd>A fast site for only the able creates inequality, but a slower site for everyone creates equality.
<dd>Note that <a href="https://bradfrost.com/blog/post/accessibility-and-low-powered-devices/">speed doubles as accessibility</a>.<br><br>

<dt>🏎️ <b>Speed</b> even over slickness
<dd><a href="https://www.nngroup.com/articles/theory-user-delight/">Delight requires showing up on time.</a>
<dd>Users think fast sites are <a href="https://craigmod.com/essays/fast_software/">more easy-to-use, well-designed, trustworthy, and pleasant</a>.
</dl>

Accordingly, I refused to compromise on security or accessibility. A speedup was worthless to me if it conflicted with those two.

### Security

MPAs and SPAs share most security concerns; they both care about [XSS/CSRF/other alphabet soups](https://cheatsheetseries.owasp.org/), and ultimately _some_ code performs the defenses. The interesting differences are **where that code lives** and **what that means for ongoing maintenance**.

<small>(Unless the stateless ideal of SPA endpoints leads to <a href="https://latacora.micro.blog/2018/06/12/a-childs-garden.html">something like JWT, in which case now you have worse problems</a>.)</small>

#### Where security code lives

Take CSRF protection: in MPAs, it manifests in-browser as `<input type=hidden>` in `<form>`s and/or `samesite=strict` cookies; a small overhead added to the normal HTTP lifecycle of a website.

SPAs have that same overhead[^4], and _also…_ 

- JS to get and refresh anti-CSRF tokens
- JS to check which requests need those tokens, then attach them
- JS to handle problems in protected responses; to repair or surface them to the user somehow

[^4]: Some say that instead of CSRF tokens, SPAs may only need to fetch data from subdomains to guarantee an `origin` header. Maybe, but [the added DNS lookup has its own performance tax](https://sites.google.com/a/chromium.org/dev/developers/design-documents/dns-prefetching), and [more often than you’d think](https://textslashplain.com/2022/03/31/chromiums-dns-cache/).

Repeat for authentication, escaping, session revocation, and all the other titchy bits of a robust, secure app. 

Additionally, **the deeper and more useful your security, the more the SPA approach penalizes all users**. That rare-but-crucial warning to immediately contact support? It (or the code to dynamically load it) can either live in everyone’s SPA bundle, ***or*** only burden an MPA’s HTML _when_ that stress-case happens.

#### What that means for security maintenance

With multiple exposed APIs, you must pentest/fuzz/monitor/etc. each of them. In theory that’s no different than the same for each `POST`able URL in an MPA, but…

Sure, 8 out of 9 teams jumped on updating that vulnerable input-parsing library. Unfortunately, Team 9’s senior dev was out this week and the juniors struggled with a dependency conflict and now an Icelandic teenage hacker ring threatens to release the records of anyone who bought laxatives and cake mix together.[^3]

[^3]: No, this was not an incident we had. For starters, the teenagers were Luxembourgian.

This problem is usually tackled with a unified gateway, which can inflict SPAs with request chains and more script kB to contact it. But for MPAs, [they already _are_ that unified gateway](https://iteo.com/blog/post/security-for-single-page-applications/#4-Session-Tracking-and-Authentication).

### Accessibility

Unlike security, **SPAs have accessibility problems _exclusive_ to them**. (Ditto any client-side routing, like [Hotwire’s Turbo Drive](https://turbo.hotwired.dev/handbook/drive).)

I’d need [code to restore standard page navigation accessibility](https://nolanlawson.com/2019/11/05/what-ive-learned-about-accessibility-in-spas/):

- Remember and restore scroll positions across navigations.
- Focus last-used element on back navigations (accounting for `autofocus`, `tabindex`, JS-driven `.focus()`, other complications…)
- Focus a page-representative element on forward navigation, even if it’s not normally focusable. [Which is filled with gotchas.](https://www.gatsbyjs.org/blog/2019-07-11-user-testing-accessible-client-routing/#takeaways-from-users-relying-on-screen-magnification)

And do all that while correctly handling **the other hard parts of client-side routing**:

- Back/forward buttons
- Timeouts, retries, and error handling
- User cancellations and double-clicks
- [History API footguns](https://medium.com/@dvoytenko/new-web-history-api-91a1a21ff7b8) — see [its proposed replacement’s reasoning](https://github.com/WICG/app-history#summary)
- Only use SPA navigation if a link is same-origin, not an in-page `#fragment`, the same `target`, needs authentication…
- Check for <kbd>Ctrl</kbd>/<kbd>⌘</kbd>/<kbd>Alt</kbd>/<kbd>Shift</kbd>/right/middle-click, keyboard shortcuts to open in new tabs/windows, or non-standard shortcuts configured by assistive tech (good luck with that last one)
- Reimplement browsers’ load/error UIs, and yours can’t be as good: they can report proxy misconfiguration, DNS failure, what part of the network stack they’re waiting on, etc.

In theory, community libraries should help avoid these problems…

> The majority of routers for React, and other SPA frameworks, do this out of the box. This has been a solved problem for half a decade at least. A website has to go out of its way to mess this up.
>
> —&thinsp;[warning: HackerNews](https://news.ycombinator.com/item?id=30534038)

We use those libraries at work, and let me tell you: we _still_ accidentally mess it up _all the time_. I asked a maintainer of a popular React router for his take:

> We can tell you when and where in the UI you should focus something, but we won’t be focusing anything for you. You also have to consider scroll positions not getting messed up, which is very app-specific and depends on the screen size, UI, padding around the header, etc. It’s incredibly difficult to abstract and generalize.

Even worse, some SPA accessibility problems are currently _impossible_ to fix:

> For example, screen readers can produce a summary of a new page when it’s loaded, however it’s not possible to trigger that with JavaScript.
>
> —&thinsp;[Why Gatsby is better with JavaScript § Accessibility](https://www.gatsbyjs.org/blog/2020-01-30-why-gatsby-is-better-with-javascript/#accessibility)

Also, remember how [speed doubles as accessibility](https://bradfrost.com/blog/post/accessibility-and-low-powered-devices/)?

> For accessibility as well as performance, you should limit costly lookups and operations all the time, but <mark>especially when a page is loading</mark>.
>
> —&thinsp;[Accessibility and Performance · Marcy Sutton](https://marcysutton.com/accessibility-and-performance)

Emphasis mine — if you should avoid heavy processing during page load, then SPAs have an obvious disadvantage.

<figure>

![A boy sits next to a skeleton on a sofa. The boy plays the cello, the skeleton holds his head in regret. Or it’s preclempt with the beauty of music? Either way, it dropped its scythe.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hzou4uicvpd3q7v5q2vk.png)

<figcaption><p>I needed something to break up the text.</figcaption>
</figure>

Let’s say I do all that, though. Sure, it sounds difficult and error-prone, but [_theoretically_ it can be done](https://www.baldurbjarnason.com/2021/single-page-app-morality-play/#i-am-avarice)… by adding client-side JS. And thus we’re back to my original problem.

## Sometimes it _is_ about the performance

You probably don’t have my 20kB budget, but [30–50kB budgets](https://dev.to/addyosmani/loading-web-pages-fast-on-a-20-feature-phone-8h6#prpl30-a-javascript-budget-for-feature-phones) are a thing I _didn’t_ invent. And remember: [anything added to a page only makes it slower](https://dev.to/tigt/making-the-worlds-fastest-website-and-other-mistakes-56na#the-goal-how-fast-is-possible).

Beyond budget constraints, [seemingly-small JS downloads have real, measurable costs on cheap devices](https://twitter.com/malchata/status/1279906756861276161):

<figure>
<center>
<table>
<thead>
<tr><th>Code<th>Startup delay

<tbody>
<tr><td>React<td>~163ms
<tr><td>Preact<td>~43ms
<tr><td>Bare event listeners<td>~7ms
</table>
</center>

<figcaption><p><a href="https://css-tricks.com/radeventlistener-a-tale-of-client-side-framework-performance/#aa-startup-time">Details in Jeremy’s follow-up post.</a> Note these figures are from a <a href="https://en.wikipedia.org/wiki/Nokia_2">Nokia 2</a>, which is more powerful than my target device.</figcaption>
</figure>

### I see you with that “premature optimization” comment

The usual advice for not worrying about front-end frameworks goes like this:

> 1. Popular frameworks power some sites that are fast, so which one doesn’t matter — they all can be fast.
> 2. _Once_ you have a performance problem, your framework provides ways to optimize it.
> 3. Don’t avoid libraries, patterns, or APIs _until_ they cause problems — or that’s how you get premature optimization.

The [idea of “premature optimization” has always been more nuanced than that](https://twitter.com/lexi_lambda/status/1488575204968345602), but this logic seems sound.

However… if you add a library to make future updates faster at the expense of a slower first load… isn’t that already a choice of what to optimize for? By that logic, you should only opt for a SPA once you’ve proven the MPA approach is too slow for your use.

> Even 10ms of high memory spikes can cause ZRAM kicking in (suuuuper slow) or even app kills. The amount of JS sent to P99 sites is _bad news_
>
> <mark>ZRAM impact is system wide. Keyboard may not show up quickly because the page used too much.</mark>
>
> Having less code can make _everything_ faster.
>
> —&thinsp;[Alex Russell](https://twitter.com/slightlylate/status/1195420738762629120)

I had performance devtools open and dogfooded my own code as I made my demo. Each time I tested a heavier JS approach, I had tangible evidence that avoiding it was _not_ prematurely optimizing.

### It’s not just bundle size

Beyond their inexorable gravity of client-side JavaScript, SPAs have other non-obvious performance downsides.

- In-page updates buffer [the Web’s streaming](https://dev.to/tigt/the-weirdly-obscure-art-of-streamed-html-4gc2), resulting in [JSON that renders slower than HTML](https://carter.sande.duodecima.technology/javascript-page-navigation/) for [lack of incremental rendering](https://jakearchibald.com/2016/fun-hacks-faster-content/). Even if you don’t _intentionally_ stream, [browsers incrementally render](https://en.wikipedia.org/wiki/Incremental_rendering) bytes streaming in from the network.<br><br>

- [Memory leaks are inevitable](https://nolanlawson.com/2022/01/05/memory-leaks-the-forgotten-side-of-web-performance/), but they rarely matter in MPAs. In SPAs, one team’s leak ruins the rest of the session.<br><br>

- [JS and `fetch()` have lower network priority than “main resources”](https://docs.google.com/document/d/1bCDuq9H1ih9iNjgzyAL0gpwNFiEP4TZS-YLRp_RuMlc/edit) (`<a>` and `<form>`). This even affects how the OS prioritizes your app over other programs.<br><br>

- Lastly, and **most importantly: server code can be measured, scaled, and optimized** until you know you can stop worrying about performance. But [**clients are unboundedly bad**, with decade-old chips and miserly RAM in new phones](https://twitter.com/nathanhammond/status/1504193960482201601). The Web’s size and diversity makes client-side “fast enough” impossible to judge. And if your usage statistics come from JS-driven analytics that must download, parse, and upload to record a user… can you be certain of low-end usage?

### Fresh loads happen more than you think

The core SPA tradeoff: the first load is slower, but it sets up extra code to make future interactions snappier.

But there are **situations where we can’t control when fresh loads happen**, making that one-time payment more like compounding debt:

<dl>
<dt>Page freezing/eviction
<dd>Mobile browsers aggressively <a href="https://developers.google.com/web/updates/2018/07/page-lifecycle-api">background, freeze, and discard pages</a>.
<dd>Switching apps/tabs unloads the first annoyingly often — ask any iOS user.

<dt>Browser/OS updates and crashes
<dd>Many devices auto-update while charging.
<dd>I’m sure you can guess whether SPAs or MPAs crash more often.

<dt>Deeplinks and new tabs
<dd>Users open things in new tabs whether we like it or not.
<dd>Outside links must do a fresh boot, weakening email/ad campaigns, search engine visits, and shared links.
<dd>Multi-device usage really doesn’t help.
<dd><a href="https://thenextweb.com/opinion/2018/04/03/rant-app-browsers-annoying-mostly-useless/">In-app browsers</a> share almost nothing with the default browser cache, which turns what users <em>think</em> are return visits into fresh load&rarr;parse&rarr;execute.

<dt>Intentional page refreshes
<dd>Users frequently refresh upon real/perceived issues, especially during support. The world considers <kbd>Refresh ↻</kbd> the fixit button, <a href="https://www.quora.com/Why-does-sometimes-stopping-a-page-load-and-then-refreshing-it-help-load-the-page-faster">and they’re not wrong</a>.
<dd>SPAs often refresh themselves for logins, error-handling, etc.
<dd>A related problem; you know those “This site has updated. Please refresh” messages? They bother the user, invoke a refresh, and replicate a part of native apps <em>nobody</em> likes.

</dl>

But when fresh pageloads are fast, you can cheat: who cares about reloading when it’s near-instant?

## SPAs are more fragile

While “rebooting” on every navigation can seem wasteful, it might be the best survival mechanism we have for the Web:

> These errors come, in large part, from users running odd niche or out-of-date browsers with broken Javascript or DOM implementations, users with buggy browser extensions injecting themselves into your scope, adblockers blocking one of your `<script>` tags, broken browser caches or middleboxes, and other weirder and more exotic failure modes.
>
> —&thinsp;[Systems that defy detailed understanding § Client-side JavaScript](https://blog.nelhage.com/post/systems-that-defy-understanding/#client-side-javascript)

<figure>

![A line chart titled “Errors grouped by browser”. The chart and samples are illegible to me: my only takeaway is that the y-axis tops out at 3,000.](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/46w4m8r1ukljix8pt6ek.png)

<figcaption><p>A typical Datadog error summary.</figcaption>
</figure>

> Given that most bugs are transient<sup>3</sup>, simply restarting processes back to a state known to be stable when encountering an error can be a surprisingly good strategy.
>
> <small><sup>3</sup> 131 out of 132 bugs are transient bugs (they’re non-deterministic and go away when you look at them, and trying again may solve the problem entirely), according to Jim Gray in <a href="http://www.hpl.hp.com/techreports/tandem/TR-85.7.html">Why Do Computers Stop and What Can Be Done About It?</a></small>
>
> —&thinsp;[Erlang in Anger § On Running Software](https://s3.us-east-2.amazonaws.com/ferd.erlang-in-anger/text.v1.1.0.pdf#chapter*.3)

- We control our servers’ features and known bugs, and can monitor them more thoroughly than browsers.

- Client error monitoring must download, run, and upload to be recorded, which is much flakier and [adds its own fun drawbacks](https://nicj.net/side-effects-of-boomerangs-javascript-error-tracking/).

- Relying on client JS is a hard known-unknown:
  - [JavaScript isn’t always available and it’s not the user’s fault](https://adamsilver.io/articles/javascript-isnt-always-available-and-its-not-the-users-fault/).
  - [Why availability matters](https://kryogenix.org/code/browser/why-availability/)
  - [Dan Abramov’s warning against React roots on `<body>`](https://medium.com/@dan_abramov/two-weird-tricks-that-fix-react-7cf9bbdef375#486f).
  - [User-Agent Interventions](https://github.com/WICG/interventions) are when browsers _intentionally_ mess with your JS.

Overall, SPAs’ reliance on client JS makes them fail unpredictably at the seams: the places we don’t control, the contexts we didn’t plan for. Enough edge-cases added up are the sum total of humanity.

## When a SPA is a good choice

Remember [PROXX](https://proxx.app/) from earlier? It did fit in 20kB, but it also doesn’t worry about a _lot_ of things:

- Its content is procedurally generated, with only a few simple interactions.
- It has few views, a single URL, and doesn’t load further data from a server.
- It doesn’t care about security: it’s a game without logins or multiplayer.
- If it weren’t accessible[^5] or broke from client-side fragility… so what? It’s a game. Nobody’s missing out on useful information or services.

PROXX is perfect as a SPA. You _could_ make [a Minesweeper clone with `<form>` and a server](https://github.com/davux/mine), but it would probably not feel as fun. Games often _should_ maximize fun at the expense of other qualities.

Similarly, [the Squoosh SPA](https://squoosh.app/) makes sense: the overhead of uploading unoptimized images probably outweighs the overhead of expensive client-side processing, plus offline and privacy benefits. But even then, there are many server-side image processors, like [ezgif](https://ezgif.com/) or [ImageOptim online](https://imageoptim.com/online), so clearly there’s nuance.

You don’t have to choose extremes! You can quarantine JS-heavy interactivity to individual pages when it makes sense: SPAs can easily embed in an MPA. (The reverse, though… if it’s even possible, it sounds like it’d inherit the weaknesses of both without any of their strengths.)

[^5]: PROXX actually did put a lot of effort into accessibility, and that’s cool.

## But if SPAs only bring ☹️, why would they exist?

We’re seeing the pendulum _finally_ swing away from [SPAs for everything](https://www.thoughtworks.com/radar/techniques/spa-by-default), and maybe you’ll be in a position someday where you can choose. On the one hand, I’d be _delighted_ to hand you more literature:

- [Maybe you don’t need that SPA](https://medium.com/@mlrawlings/maybe-you-dont-need-that-spa-f2c659bc7fec)
- [Be progressive](https://adactio.com/journal/7706)
- [Progressive Enhancement: Still Not Dead.](https://thatemil.com/blog/2013/07/02/progressive-enhancement-still-not-dead/)

On the other hand…

> Please beat offline-first ServiceWorker-cached application shells or even static HTML+JS on a local CDN with a cgi page halfway across the globe.
>
> —&thinsp;[Mihail Malo](https://dev.to/qm3ster/comment/1n3d4)

This is true. It’s not enough for me to say “don’t use client-side navigation” — those things are important for any site, whether MPA or SPA:

- Offline-first reliability and speed
- Serving as near end-users as possible
- Not using CGI (it’s 2022! at least use FastCGI)

So, next time: **can we get the benefits that SPAs enjoy, without suffering the consequences they extremely don’t enjoy?**