More thoughts on SPAs

My last post (“The balance has shifted away from SPAs”) attracted a fair amount of controversy, so I’d like to do a follow-up post with some clarifying points.

First off, a definition. In some circles, “SPA” has colloquially come to mean “website with tons of JavaScript,” which brings its own set of detractors, such as folks who just don’t like JavaScript very much. This is not at all what I mean by “SPA.” To me, an SPA is simply a “Single-Page App,” i.e. a website with a client-side router, where every navigation stays on the same HTML page rather than loading a new one. That’s it.

It has nothing to do with the programming model, or whether it “feels” like you’re coding a Single-Page App. By my definition, Turbolinks is an SPA framework, even if, as a framework user, you never have to dirty your hands touching any JavaScript. If it has a client-side router, it’s an SPA.

Second, the point of my post wasn’t to bury SPAs and dance on their grave. I think SPAs are great, I’ve worked on many of them, and I think they have a bright future ahead of them. My main point was: if the only reason you’re using an SPA is because “it makes navigations faster,” then maybe it’s time to re-evaluate that.

Jake Archibald already showed way back in 2016 that SPA navigations are not faster when the page is loading lots of HTML, because the browser’s streaming HTML parser can paint above-the-fold content faster than it takes for the SPA to download the full-fat JSON (or HTML) and manually inject it into the DOM. (Unless you’re doing some nasty hacks, which you probably aren’t.) In his example, GitHub would be better off just doing a classic server round-trip to fetch new HTML than a fancy Turbolinks SPA navigation.

That said, my post did generate some thoughtful comments and feedback, and it got me thinking about whether there are other reasons for SPAs’ recent decline in popularity, and why SPAs could still remain an attractive choice in the future for certain types of websites.

Core Web Vitals

In 2020, Google announced that the Core Web Vitals would become a factor in search page rankings. I think it’s fair to say that this sent shockwaves through the industry, and caused folks who hadn’t previously taken performance very seriously to start paying close attention to their site speed scores.

It’s important to notice that the Core Web Vitals are very focused on page load. LCP (Largest Contentful Paint) and FID (First Input Delay) both apply only to the user experience during the initial navigation. (CLS, or Cumulative Layout Shift, applies to the initial navigation and beyond; see note below.) This makes sense for Google: they don’t really care how fast your site is after that initial page load; they mostly just care about the experience of clicking a link in Google and loading the subsequent page.

Regardless of whether these metrics are an accurate proxy for the user experience, they are heavily biased against SPAs. The whole value proposition of SPAs (from a performance perspective at least) is that you pay a large upfront cost in exchange for faster subsequent interactions (that’s the theory anyway). With these metrics, Google is penalizing SPAs if they render client-side (LCP), load a lot of JavaScript (FID), or render content progressively on the client side (CLS).

A classic MPA (Multi-Page App) with a dead-simple HTML file and no JavaScript will score very highly on Core Web Vitals. Miško Hevery, the creator of Qwik, has explicitly mentioned Core Web Vitals as an influence on how he designed his framework. Especially for websites that are very sensitive to SEO scores, such as e-commerce sites, the Core Web Vitals are pushing developers away from SPAs.

Code caching

This was something I forgot to mention in my post, probably because it happened long enough ago that it couldn’t possibly have had an impact on the recent uptick in MPA interest. But it’s worth calling out.

When you navigate between pages in an MPA, the browser is smart enough not to parse and compile the same JavaScript over and over again. Chrome does it, Firefox does it, Safari does it. All modern browsers have some variation on this. (Legacy Edge and IE, may they rest in peace, did not have this.) Incidentally, this optimization also exists for stylesheet parsing (WebKit bug from 2012, Firefox bug, demo).

So if you have the same shared JavaScript and CSS on multiple MPA pages, it’s not a big deal in terms of subsequent navigations. At worst, you’re asking the browser to re-parse and re-render your HTML, re-run style and layout calculation (which would happen in an SPA anyway, although to a lesser degree thanks to techniques like invalidation sets), and re-run JavaScript execution. (In a well-built MPA, though, you should not have much JavaScript on each page.)

Throw in paint holding and the back-forward cache (as discussed in my previous post), as well as the streaming HTML mentioned above, and you can see why the value proposition of “SPA navigations are fast” is not so true anymore. (Maybe it’s true in certain cases, e.g. where the DOM being updated is very small. But is it so much faster that it’s worth the added complexity of a client-side router?)

Update: It occurred to me that a good use case for this kind of SPA navigation is a settings page, dashboard, or some other complex UI with nested routes – in that case, the updated DOM might be very small indeed. There’s a good illustration of this in the Next.js Layouts RFC. As with everything in software, it’s all about tradeoffs.

Service Worker and offline MPAs

One interesting response to my post was, “I like SPAs because they preserve privacy, and keep all the user data client-side. My site can just be static files.” This is a great point, and it’s actually one of the reasons I wrote my Mastodon client, Pinafore, as an SPA.

But as I mentioned in my post, there’s nothing inherent about the SPA architecture that makes it the only option for handling user data purely on the client side. You could make a fully offline-powered MPA that relies on the Service Worker to handle all the rendering. (Here is an example implementation I found.)

I admit though, that this was one of the weaker arguments in my post, because as far as I can tell… nobody is actually doing this. Most frameworks I’m aware of that generate a Service Worker also generate a client-side router. The Service Worker is an enhancement, but it’s not the main character in the story. (If you know a counter-example, though, then please let me know!)

I think this is actually a very under-explored space in web development. I was pitching this Service-Worker-first architecture back in 2016. I’m still hopeful that some framework will start exploring this idea eventually – the recent focus on frameworks supporting server-side JavaScript environments beyond Node (such as Cloudflare Workers) should in theory make this easier, because the Service Worker is a similarly-constrained JavaScript environment. If a framework can render from inside a Cloudflare Worker, then why not a Service Worker?

This architecture would have a lot of upsides:

  1. No client-side router, so no need to implement focus management, scroll restoration, etc.
  2. You’d also still get the benefits of paint holding and the back-forward cache.
  3. If you open multiple browser tabs pointing to the same origin, each page will avoid the full-SPA JavaScript load, since the main app logic has already been bootstrapped in the Service Worker. (One Service Worker serves multiple tabs for the same origin.)
  4. The Service Worker can use ReadableStreams to get the benefits of the browser’s progressive HTML parser, as described above.
  5. Memory leaks? I’ve harped on this a lot in the past, and admittedly, this wouldn’t fully solve the problem. You’d probably just move the leaks into the Service Worker. But a Service Worker has a fire-and-forget model, so the browser could easily terminate it and restart it if it uses up too much memory, and the user might never notice.

This architecture does have some downsides, though:

  1. State is spread out between the Service Worker and the main thread, with asynchronous postMessage required for communication.
  2. You’d be limited to using IndexedDB and caches to store persistent state, since you’d need something accessible to the Service Worker – no more synchronous LocalStorage.
  3. In general, the simplified app development model of an SPA (all state is stored in one place, on the main thread, available synchronously) would be thrown out the window.
  4. No framework that I’m aware of is doing this.

I still think the performance and simplicity upsides of this model are worth at least prototyping, but again, it remains to be seen if the DX (Developer Experience) is seamless enough to make it viable in practice.

The virtues of SPAs

So given everything I’ve said about SPAs – paint holding, the back-forward cache, Core Web Vitals – why might you still want to build an SPA in 2022? Well, to give a somewhat hand-wavy answer, I think there are a lot of cases where an SPA is a good choice:

  1. You’re building an app where the holotype matches the right use case for an SPA – e.g. only one browser tab is ever open at a time, page loads are infrequent, content is very dynamic, etc.
  2. Core Web Vitals and SEO are not a big concern for you, e.g. because your app is behind a login gate.
  3. There’s a feature you need that’s only available in SPAs (e.g. an omnipresent video player, as mentioned in the previous post).
  4. Your team is already productive building an SPA, because that’s what your favorite framework supports.
  5. You just like SPAs! That’s fine! I’m not going to take them away from you, I promise.

That said, my goal with the previous post was to start a conversation challenging some of the assumptions that folks have about SPAs. (E.g. “SPA navigations are always faster.”) Oftentimes in the tech industry we do things just because “that’s how things have always been done,” and we don’t stop to consider if the conditions that drove our previous decisions have changed.

The only constant in software is change. Browsers have changed a lot over the years, but in many ways our habits as web developers have not really adjusted to fit the new reality. There’s a lot of prototyping and research yet to be done, and the one thing I’m sure of is that the best web apps in 10 years will look a lot different from the best web apps built today.

Next post: State is hard: why SPAs will persist

15 responses to this post.

  1. Good points raised. I hope we reach a stage when we choose the right tool for the job. The novelty of SPAs resulted in everyone using an SPA for everything, including personal blogs or content based sites.

    P.S.

    CLS (Cumulative Layout Shift) only apply to the user experience during the initial navigation

    Small correction that CLS is measured throughout the user session, not just on initial loading.

    Reply

  2. I explored rendering HTML from inside a service worker, but abandoned the idea. The dev experience and first page load are finicky because service workers are installed / updated after page load, you’re not always sure what you’re seeing is current, and any time you bypass the cache you also bypass the service worker. I liked the idea, but it felt like it was gonna be a janky way to build an app.

    Reply

    • Yeah, that’s fair. It could be that it’s just too zany of an idea to really have legs.

      The problem of always needing to support the current Service Worker plus the “n minus 1” Service Worker is something I’ve struggled with in the past myself. I don’t think it’s insurmountable, but it is tricky.

      Again, conceptually SPAs are much simpler, since everything’s on the main thread. Then again, everything’s on the main thread. :)

      Reply

    • I just have a script that reloads the page so it uses the service worker right away. But I have run into problems where it goes into an infinite loop. I haven’t debugged that yet though. As it is just a personal app and 99.999% of the time it works correctly.

      Reply

  3. Posted by pooroligarch on May 28, 2022 at 12:08 AM

    This is way better than your old take. There are applications for both approaches – MPA for content heavy sites (blogs, eshops, company sites) and SPA for very dynamic apps like social media. I think the best tools for that are hybrid SSG frameworks like Gatsby where you can use full CSR for pages that can’t be generated at build time (content creation, user settings, login/signup…)

    Reply

  4. […] Nolan Lawson wrote an insightful article recently about how he senses that the balance has shifted away from single page apps. I’ve been sensing the same shift in the zeitgeist. That said, both Nolan and I keep an eye on how browsers are evolving and getting better all the time. If you weren’t aware of changes over the past few years, it would be easy to still think that single page apps offer some unique advantages that in fact no longer hold true. As Nolan wrote in a follow-up post: […]

    Reply

  5. […] développeur Nolan Lawson a posté trois articles sur la pertinence du choix du modèle SPA (Single Page Application) lorsqu’on met en […]

    Reply

  6. […] seems a number of folks really clung to that first part because Nolan published a follow-up to clarify that SPAs are far from […]

    Reply

  7. I’ve written an offline MPA in a service worker. I really liked the simplicity. Any saving of forms all I need to do is issue a 302 redirect and redo the page. No tracking of state at all.

    @ github jon49 WeightTracker tree master WeightTracker FrontEnd

    The problems I’ve experienced with it are:

    Reloading the page is a bit janky since it doesn’t stay in the original scroll position.
    Sometimes I still get the white flash if I navigate between pages too quickly. Why does this happen?
    Web browsers still haven’t implemented lazy module loading in service workers. At one point I created my own but it was too hard to maintain and work with. For large apps having lazy loading (dynamic imports) would be a must.
    Debugging in a service worker can be a bit painful sometimes as browsers haven’t made the experience as seamless as it ought to be.

    But really, you don’t really need a framework. Or you could use a small library if you really wanted to. The code is pretty straightforward. A tutorial/wiki would be good enough.

    I think a good mix of MPA/SPA would be good. I really like the library HTMX. I simplified it further and created HTMF (which needs to be changed to follow HTMX more as it has good heuristics that it has come up with).

    But if I’m going to create another offline app I would reach for the MPA model and add in HTMF or HTMX for any SPA-like behavior. Sometimes full client side code is needed. But most apps don’t need that.

    Reply

    • Sometimes I still get the white flash if I navigate between pages too quickly. Why does this happen?

      Interestingly, I just tried testing this in instantmultipageapp.com. In Firefox and WebKit I never see any white flashing, but in Chrome I do occasionally see it. It seems to correspond to the more heavyweight pages, like the Images and Blog page. I do note that in the Chrome article on paint holding, they say that they aim to “reduce” the white flash, not eliminate it. I also tried inlining the CSS into <style>s, but it didn’t seem to make a difference. Maybe Chrome’s paint holding implementation needs some work!

      Reloading the page is a bit janky since it doesn’t stay in the original scroll position.

      I’m not seeing problems with the scroll position in any browser. I scroll down the Blog page, press refresh, and the scroll position stays put.

      Reply

      • Yeah, I guess I’m talking about the app I made. Not the example app. The scroll position changes when I click the save button on my app as I do a redirect to the same page. Maybe that is a good thing as it shows that the data was actually saved to the user since I’m not using toasters.

        Yeah, the flash does happen on Chrome as that is where I usually use my web app and even more so on the pages where I display a table with about 300+ rows. I usually use Chrome as that is my “app” browser and I have Firefox set up to delete cookies all the time. I suppose I could have it not delete cookies for my app though. And I think the developer experience in Service Workers is nicer in Chrome, if I remember right – that might have been the main reason for moving over to Chrome.

      • Oh, and thanks for doing the research on the white flash! Now I know that it is more of a Chrome problem. I hope they fix that someday.

  8. State is spread out between the Service Worker and the main thread, with asynchronous postMessage required for communication.

    For the life of me I’ve never been able to get this to work. Two-way asynchronous communication between the front end and the service worker. I can call the service worker from the front end. But not make a message to the main thread. I have yet to find a good tutorial.

    Reply

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.