Old school architectures making a return, part 2: Backend rendered webapps
Separating backend and frontend is an evolutionary dead-end, or at least frequently, a complication.
The last 15 years has seen us move from backend-rendered webapps to Single-Page-Applications (“SPA”), to hybrid approaches like Next.js and Remix.
In the 2000’s, most webapps were simple backend-rendered things, usually following a Model-View-Controller pattern. When Gmail arrived on the scene, everyone got excited about how Google had managed to make a webapp so dynamic and desktop-app like. What followed was the evolution that led us to modern SPA web-frameworks like Vue and React, where eventually, frontends are in effect their own applications, talking to a server, usually via a JSON-over-REST API.
As a one-size-fits-all approach has been found wanting, we have also had novel new approaches, like the hybrid frontend frameworks like Next.js, Remix & Nuxt, where pages are sometimes rendered on the backend, sometimes on the frontend.
The benefits of SPA and hybrid SPA/SSR apps
Pushing logic to the client’s browser is not without merit. In a pure SPA approach, an app can be distributed via CDN, and a lot of logic can be executed locally in the client’s browser. This frequently gives the perception of a faster app to end-users.
A hybrid SPA/SSR (“Server-Side Rendered”) app achieves similar goals, while allowing certain necessary aspects to be done on the server-side, and also improving the ability to Search Engine Optimize content.
Yet another benefit is that you frequently get an API for your backend “for free”, that other clients, internal or third-party, can also use. However, as we’ll soon discuss, “free” things are rarely actually free, they come at a cost.
A final benefit of modern web frameworks in particular, is that they have adopted a component-based approach: this frequently means it's easy to re-use frontend components across the application, or even across applications.
The drawbacks of SPA & SPA/SSR hybrids
The SPA & hybrid approaches come with quite a few issues, let’s have a look at some of them:
The “free” API duplicates work
With a separate frontend and backend, communicating via an API comes a cost: duplication. Frequently user logic, flows and validation are duplicated across both. In bad cases, the responsibilities get muddled, perhaps too much logic gets pushed to the frontend, exposing security weaknesses in the backend.
I’d estimate (completely made up, but plausible based on anecdotal experience), a strict frontend/backend split results in between 40-80% more work compared to just a simple backend rendered app.
The API is frequently unsuited for other clients
The “Backend-for-frontend” pattern is a tacit admission that different clients require slightly different API’s. This means that the API designed for a webapp, will rarely be well-suited for a mobile app, or some headless automation. In practice, you’ll either end up with suboptimal interaction patterns to your API, or multiple API’s tailored for different clients.
The “free” API you got with your webapp gave you nothing but extra effort. Even where the API fits, clients are likely to only need some small subset of the API.
Testing becomes challenging
Testing is hard as it is. With effectively separate apps, testing just got even harder. Instead of just writing unit/integration tests with something like test-containers and an embedded web-server, testing the entire app end-to-end, you need to either fake the backend, risking drift in your fakes from the real behavior, or deploy the entire stack, and write flaky end-to-end tests, that take long to run, are difficult to write, easily fall apart, and eventually no one pays attention to.
Lost productivity
So if we duplicate work, make testing harder for ourselves, we’ve already damaged our productivity quite significantly, haven’t we?
To add insult to injury, we also have to spend time and energy either finding ways of sharing representations of types, or worse, duplicating them on server and frontend respectively. Even where this is trivial, and assuming we have fullstack developers who do both sides, they still have to jump between codebases potentially, doing mental context switches as they transition between frontend and backend.
SPA/SSR hybrids are difficult to reason about
Why don’t we just use Next.js/Remix/Nuxt/insert-hybrid-framework-here as a full-stack framework? We can use SSR with those?
Well, yes you can. But in my experience, it can be difficult to reason about when exactly something is client-rendered, vs server-rendered. And in the wild, I have seen instances where this has led to passwords and other secrets leaking to clients.
The Next.js approach in particular of having “use client” and “use server” directives are in my opinion, an absolutely horrible approach just waiting to go wrong.
The Solution: an old-school alternative that isn’t entirely old-school
You might have read between the lines that I am about to suggest we go back to writing server-side rendered webapps in your backend language of choice, and you wouldn’t be entirely wrong.
But, there’s a twist:
I don’t think we should throw out the baby with the bathwater. We can still update portions of a page, but instead use tools like HTMX, to build hypermedia driven applications. This allows us to get the best of both worlds: dynamic webapps, swapping out elements of a page, but where the code is cohesively contained in one codebase, for which the execution is easy to reason about and easy to test.
Another part that would get painful in a backend-driven webapp is writing reusable components and design systems. However, for this too, there is a solution: the Web Components standard, that can be implemented with libraries such as Lit.
In other words, with Web Components, there is still a bit of room for “frontend web development” as we know from the last decade, but with a massively cut down scope. And with hypermedia and HTMX, we cut out the unecessary step of constantly turning JSON into HTML.
Not every problem is suited to going back to basics
Of course, the world is full of nuance, problems of varying complexity. There are surely problems that require a very frontend heavy approach to solve. So, as always, consider the trade-offs.
My preference towards backend-rendered webapps leaning on a hypermedia-approach with web components is for the 80% of webapps that are below the complexity threshold where something else is necessary.
There are no blanket solutions, merely trade-offs to be weighed. As in the first part of this series of posts, my main recommendation is to stop defaulting and start thinking.
For the majority of apps, there are simpler ways. For some, the dominant approaches of the last decade might be perfect.