My approach to web app development

11 April 2023

Photo by Ferenc Almasi on Unsplash.


This blog post outlines the way I build web applications. As the title suggests, this is my approach, and is what works for me. This is a heavily opinionated blog post, but I hope that at least some concepts suggested here will be helpful for others.

Web apps vs. websites

The first thing to clarify is that web apps should be built differently from websites. Web apps are focused on functionality, while web sites are focused on content. Web apps may have a small amount of content, and web sites may have a small amount of functionality, but the main focus is different. Additionally, web apps are designed to be used more than once, while for web sites they may or may not be used regularly. This distinction is important to consider in order to optimise the user experience.

In reality, there really is a spectrum between these two extremes. We will be focusing on the web app extreme of this spectrum.

Web app to web site spectrum

To server-render or not to server-render?

A lot of the development happening with frameworks right now is about server rendering. By server rendering I’m referring to any type of rendering that happens remotely (not on the client). This definition includes SSR (server-side rendering), SSG (static site generation), and ISR (incremental static regeneration). On the other hand, CSR (client-side rendering) is rendering that happens on the client, which is the ‘default’ for most frameworks (React, Vue, Angular).

There are three combinations of server-rendering and client-rendering (excluding when there are none):

server side spectrum

Server-rendered (no client-rendering)

This approach is reminiscent of frameworks such as PHP/Django. The server renders the HTML and sends it to the client. The client then receives the HTML and displays it. The client does not render anything. Application is entirely stored on the server, through cookies or sessions. Data will not update without a refresh. Mutations are performed through forms which submit POST requests to the server, and cause the page to refresh.

Progressive Enhancement

This is the approach taken by Next.js, Remix, etc. The server renders the HTML and sends it to the client. The client then receives the HTML and displays it. This is good for performance and SEO, as data is displayed without needing JS to load. The client then performs a process called ‘hydration’, which means that it takes the HTML and turns it into a fully interactive application. The application state is stored on the client. Data will update without a refresh (with proper data fetching libraries). Mutations can be performed without refreshing the page.

Client-rendered (no server-rendering)

The server sends the client a blank HTML page, which references some scripts which render the page. The application state is stored on the client. Data will update without a refresh (with proper data fetching libraries). Mutations can be performed without refreshing the page. Often, the app uses client side routing, which means that navigation between pages happen without a refresh. These apps are often large, as the entire app is downloaded to the client, rather than a single page.

After looking at these three, Progressive Enhancement seems like the most obvious choice. In progressive enhancement, data is fetched on the server, and then hydrated on the client. This means that the user gets the best of both worlds: the performance and SEO benefits of server-rendering, and the interactivity of client-rendering.

PWAs

Progressive Web Apps (PWAs) are applications built using web technologies to deliver experiences that rival those of native apps. A key capability of PWAs is that they use service workers. Service workers are a type of web worker that runs in a separate thread, and among their capabilities is the ability to cache and intercept network requests.

Web applications should behave like native apps. Native apps don’t download their entire codebase from a server every single time they are opened. Native apps don’t need to be connected to the internet to work. If web apps are to be viable alternatives to native apps, they need to have these capabilities.

The web applications I build rely extensively on service workers. On first load, the web app is downloaded from the server and cached. On subsequent loads, the web app is loaded exclusively from the cache. When there is an update, the new code is downloaded in the background. When the user refreshes the page, the new code is loaded from the cache.

For this to work, there needs to be some kind of client-side rendering, as the app is largely decoupled from the server. This automatically rules out the PHP-style approach, with no rendering on the client.

Progressive Enhancement or CSR?

The question now is whether to use progressive enhancement or client-side rendering. For the reasons outlined above, progessive enhancement will often provide the best user experience. However, for PWAs, progressive enhancement has diminishing returns. Consider that for devices supporting PWAs, the client only loads the app once, and then it is cached. This means that the benefits of progressive enhancement are only gained on first load. And since apps are used numerous times, these benefits are not as important, as for the vast majority of times, progressive enhancement has no benefit. caniuse.com estimates that 96.4% of users globally support service workers. As nice as it would be to support the remaining 3.6%, for most developers, the cost of supporting these users is not worth it.

Architectural Divergence

As outlined in Remix’s article, architectural divergence leads to extra complexity, which leads to poor DX (developer experience). The architectural divergence Remix is talking about is about the divergence between SSG and CSR. Remix’s solution is to fetch data on the server through SSR only. This is a great solution for websites, but as outlined above it doesn’t work for PWAs. The architectural divergence in progressive enhancement is the initial fetching on the server, and the partial updates on the client. This leads to needing to write logic and data fetching code that runs both on the server and client, leading to an increase in complexity caused by architectural divergence.

As a result, I opt to go for CSR-only for PWAs. This removes the additional complexity of data fetching on the server, which leads to a better DX. Progressive enhancement may be an ideal solution, but unless you have the time and resources to support the 3.6% of users who don’t support service workers, it is not worth it.

Making SPAs work well

There are a lot of issues with SPAs. Here are the two most common issues:

  • SEO - Search engines can’t crawl SPAs.
  • Performance - SPAs are slow to load, and slow to run.

SEO

Many people point to SEO as an issue for SPAs. However, we are talking about using SPAs for apps, not websites. Since apps are about functionality, not content, there is not much for the search engine to crawl in your app anyway. As for discoverability, you should build a website landing page to promote your app, using SSR or SSG. This landing page should be discoverable by search engines, and should link to your app.

Performance

This isssue has two sub-issues: initial load time, and runtime performance.

Initial load time

SPAs tend to be quite large, as they contain the entire app and all of its pages. This can easily lead to bundle sizes in excess of several megabytes. However, since we are building PWAs, this download only needs to happen once, as it is then cached. Additionally, through code-splitting, we can ensure that on first launch, only the code needed to render the current page is downloaded. This makes initial load time much better. And even if after this, load times are still bad, it only needs to happen once, so the impacts are minimal. After this initial load, service workers should start caching the rest of the app in the background.

Runtime performance

Since the app is using CSR, all of the rendering logic happens on the client. This moves the bottleneck from the network to the CPU. This is usually good, since for most devices it is faster to do things locally than to wait for the network. However, for extremely slow devices, this can be an issue. There is no easy answer to this problem, but generally performance issues are caused by poorly written code, and can be solved by refactoring. Ultimately, you should tailor your apps to work well for most of your users. You should collect performance metrics such as Core Web Vitals, and use them to make informed decisions about what to optimise. If most of your users are on devices that cannot support an SPA approach, than you should consider another approach.

Data fetching

One of the key principles I use when building apps is to have some data available as soon as possible, and try to keep that data up to date. Having data available as soon as possible, can be accomplished by having a cache, so that the data can loaded from the cache quickly. Keeping this data up to date could be accomplished by refreshing the data on a certain interval, and doing it whenever the user refocuses on a page. This can be quite hard to implement, so libraries such as SWR and TanStack Query exist to make this easier.

Conclusion

In this article, I outlined the pros and cons of the three main approaches to building web apps: SSR, CSR, and progressive enhancement. I then outlined the pros and cons of using progressive enhancement for PWAs. I concluded that for PWAs, CSR is the best approach, as it removes the complexity of data fetching on the server, and leads to a better DX. I also outlined some of the issues with SPAs, and how to solve them. Finally, I outlined how to keep data up to date. Keep in mind that this is just how I build apps, and you should do what leads to the best user experience for your users.