SPAs, PWAs and SSR
Single Page Apps, Progressive Web Apps and classic Server side rendered websites are often seen as orthogonal approaches to building web apps where only one is best suited for a particular project and one has to make a choice to go with one of them. In this post we'll explore why that doesn't have to be the case, since all 3 approaches can actually be combined in order to achieve the best result.
Modern websites are in many cases not really websites anymore, but in fact full blown apps with desktop-grade feature sets and user experiences that happen to run in a browser as opposed to standalone apps. While the much-loved Spacejam Website was a pretty standard page in terms of interactivity and design only about 2 decades ago
we can now go to Google Maps, zoom and rotate the earth in 3D space, measure distances between arbitrary points and have a look at our neighbor's backyard:
What all this leads to is that for many of these highly-interactive, feature-rich and shiny apps that are being built today, the first impression that users get is often this:
TTFMP: Time to first meaningful paint
This is the time when the browser can first paint any meaningful content on the screen. While the time to first paint metric simply measures the first time anything is painted (which would be when the loading spinner is painted in the above example), for an SPA the time to first meaningful paint only occurs once the app has started and the actual UI is painted on the screen.
TTI: Time to interactive
This is the time when the app is first usable and able to react to user inputs. In the above example of the SPA, time to interactive and time to first meaningful paint happen at the same time which is when the app has fully started up, has painted the UI on screen and is waiting for user input.
anchorThe App Shell Model
Although this does not improve the app's TTFMP or TTI, at least it gives the user a first visual impression of what the app will look like once it has started up. Of course the app shell can be cached in the browser using a service worker so that for subsequent visits it can be served from that instantly.
anchorBack to SSR
The only really effective solution though for solving the problem of the meaningless initial UI - be it an empty page, a loading indicator or an app shell - is to leverage server-side rendering and respond with the full UI or something that's close to it for the initial request.
Of course it wouldn't be advisable to go back to classic server-side rendered websites completely, dropping all of the benefits that Single Page Apps come with (instance page transitions once the app has started, rich user interfaces that would be almost impossible to build with server side rendering, etc.) A better approach is to run the same single page app that is shipped to the browser on the server side as well as follows:
- the server responds to
GETrequests for all routes the single page app supports
- once a request comes in, the server constructs an application state from the request path and potentially additional data like a session cookie and injects that into the app
- it then executes the app and renders the app's UI into a string, leveraging libraries like SimpleDOM or jsdom
- that string is then served as the response to the browser's initial request
- the pre-rendered response still contains all
<script>tags so that the browser would load and execute these scripts and start up the app in the browser as usual
anchorSPA + SSR + PWA
Combining SPAs with classic SSR, we get the best of both worlds - a fast TTFMP plus the benefits of an SPA like immediate page transitions, vivid UX etc. On top of that, patterns of PWAs can be added, for example caching the initial pre-rendered response in a service worker so that it can be shown immediately on subsequent visits or showing the app shell from the service worker cache and then injecting the SSR response into that which is likely available before the app has started up.
When leveraging SSR for SPAs, it is important to get some aspects of the deployment right. Pre-rendering the application for the first request is only an improvement over the classic way of serving an SPA and should not be a requirement to use the app. Neither should it slow down delivery of the app. To make sure these requirements are met, it is important to make sure of two things:
- The pre-rendering must run within a timeout so that if for some reason it takes longer than x ms or whatever a reasonable threshold for a particular app might be, the server cancels the pre-renderer and instead serves the SPA the classic way (which is responding with the static, empty HTML file).
- Likewise, errors in the pre-renderer should not be forwarded to the users and thus block them from booting the app in the browser. Whenever the pre-renderer encounters an error, it should fall back to serving the app the classic SPA way just like if it runs into the timeout.
We implemented the patterns described in this post in Breethe, a PWA for accessing air quality data for locations around the world that we built as a tech showcase. Breethe is completely open source and available on github and we encourage everyone interested in the topic to check it out for reference.
Server-side-rendering an SPA comes with a drawback which is added latency. When serving an SPA as a static HTML file that contains only a loading state or an app shell, the HTML file can be served from a CDN which reduces latency. When serving the response for the initial request from a Node server, there will always be some additional latency. The Node server will likely not be running on an edge note in a CDN, thus connecting to it will take the user slightly longer than connecting to an edge node. Also, the server takes some time to run the app, capture what it renders and respond with that, adding further latency. Thus, the browser will know slightly later which scripts to load and thus start loading them slightly later which leads to a longer TTI.
However, with average TTI measurements in the range of several seconds for many sites in particular when requested from mobile devices, an added latency of a few hundred milliseconds might be well worth it in many cases. Without SSR, TTFMP is generally equal to TTI for SPAs and PWAs - with SSR, TTFMP occurs as soon as the initial response is received while TTI is only slightly delayed. So while the user needs to wait slightly longer for the app to be fully started up, the app's UI (and content) is available pretty much immediately. Whether that is a valuable improvement is a case-by-case decision of course. In the example of Breethe, when looking at the result page for a particular location, it's a pretty obvious decision:
While the user might need to wait for a few seconds (e.g. on a low performance mobile device with a spotty network connection) for the app to have fully started up and be interactive, the air quality data that they are interested in is available immediately.
I will elaborate on how exactly the approach works in the next post of this series so stay tuned!