Handling version updates of Single Page Applications while rendering client side
Dennis Gijzen - May 20, 2024
When releasing a new version of an application that is actively being used, we would like it if our clients are ensured of an as fluent as possible transition between the different versions. When dealing with a Single Page Application (: SPA) running at the client’s side, updating the version might give us a bad User Experience (: UX) if not properly thought through. Depending on the application a stale version at the client’s side might even be a concern for security in some minor cases. So not only do we need to have a fluent as possible transition between the two versions we would also need to force any client to update within a predefined window of time. This time window should preferably be taken as relative time (for example the first API call made after availability) and not absolute time (three seconds after deployment). Because an SPA running on the client side is mostly not in our direct sphere of influence we cannot update each client with of a push on a button unless we would use something such as websockets and keep active connections. Though websockets might not be ideal, as would be argued in this text. What we can do instead, however, is to configure the application so that a client would update as soon as possible and that this same configuration also would tackle the requirement to do this as fluent as possible for our users.
The following text is about SPAs that are Client Side Rendered. For Server Side Rendering some of the given solutions might be different. The article will not break down how to handle the backend in conjunction with version updates but glances over some of the challenges at the end of the text.
Drawing out the problem
A new version of our application is about to be released. We would like our clients to start using this new version and stop using the current version as soon as possible. In a nutshell, to do so need to tackle the following points:
Update the SPA version at the client’s side.
Let all active sessions be switched before X time.
Let all new sessions start working with the new version.
(Update the backend at the server’s side.)
(Synchronize the process such that in the process of switching both versions don’t break.)
The process of switching versions does depend on switching the application on the backend too and synchronizing this process. The text will not break down how to handle the backend in conjunction with version updates but glances over some of the challenges at the end of the text.
Recap on the main construct of SPAs
A Single Page Application is a web application running inside the client’s browser. The web application, instead of the browser, handles routing itself dynamically and constructs, instead of fetches, the pages per URL thereby creating a feeling of a native app. The application would still communicate with the server mainly to retrieve and send needed data or to fetch chunks to build itself up gradually as happens with Lazy Loading. On bootstrap, which happens when the user opens a browser window and navigates to the domain of the web application, the application checks if it has all the essentials to start running the associated page with the URL. These essentials includes a file with an index of available chunks and their relation to the modules and components inside the SPA. If the application deducts that it does not have the necessary chunks for the current requested route it would start retrieving the minimum necessary required chunks using the index file previously mentioned. This minimum package to run the current route is created by the developers and how they would set up Lazy Loading. Eventually the application might come to a point where it either has collected all the available chunks of the complete application or either the needed chunks of the routine (or access) the active user has. This point is the moment communication with the server solely starts to be for sending and retrieving data instead of also being for fetching chunks to build itself up. The communication to the server might be over a websocket, an http-client, or a combination of both.
Having recapped all that, now comes along a version update.
Updating the SPA at the client’s side
To guide the client to use the new version we could split the problem in two lanes. The first lane will be new client sessions the second lane would be active client sessions.
Updating new client sessions
When a client opens a browser window and navigates to somewhere into the domain of the application the browser would initially fetch the application from a webserver. Any starting point, any URL, inside the application’s domain, may it be root or more specified, will return the essentials for the bootstrap process and these would always be the same on any route. The utmost first session a client starts will therefor always get that version the server serves, confining that a developer always let the server serve the latest version, therefor the application’s latest version.
If a new session is opened which is not the utmost first time a client visits the application’s domain the browser will step through the following: there is a check to see if there is a cached page for this route available, if so, it acts according to the set cache control on these assets, if not, it would go and retrieve the page needed to satisfy the route. This is the part where the right configuration would make sure that any new session would retrieve the new version from the server. This can be achieved by setting up a correct ‘cache busting’ mechanism. Configuring the cache-control headers in the server’s configuration could tell a browser to always do a check-up with the server for freshness prior to showing the client. This could ensure that new sessions start with the latest version.
A correct cache-control header configuration would be ‘Cache-Control: no-cache’ (how dubious the name sounds it does cache: Mozilla: Cache Control). This setting relies on a mechanism to validate freshness, which can be done by tagging our resources with an ETag number that is send along with the header. There is no restriction on how the value of a ETag number is generated Mozilla: Caching Validation. As a side-note: do make sure when serving from multiple servers the ETags for each version on different servers match. Depending on the server’s configuration this might not always be the case which might give a false negative for the validation causing the client to unnecessarily re-fetch the application.
While the header setting ‘Cache-Control: no-cache’ should be enough, some would recommend additional ‘Cache-Control’ settings like ’Cache-Control: no-store, no-cache, max-age=0, must-revalidate, proxy-revalidate’. The reason for this is historical. This configuration is also known as the kitchen-sink cache control. Mozilla: Caching Types of Caching. It was used to make sure that while serving content on the web an (estranged) old and stale proxy of which you had no control would have no idea of the newer protocols of caching content and would cache yours anyways. This time period is pre-TLS. However, if you would feel unsure, for example with the proxies used that would sit in between an organization and the outside world, with no-cache only, be free to add extra configuration, it wouldn’t do much harm.
First lane checked. With proper cache busting set all new sessions would get the newest version of the application.
Updating active client sessions
Next lane is active sessions. For active sessions two different scenarios need to be explored too. In an active session the SPA might either still be in the process of retrieving chunks or it has crossed that point from on which it does not need to fetch more chunks for the current user’s session.
SPAs use a combination of name + hash to identify chunks of the application. This hash changes for every new production build (most frameworks would handle hashing of chunks automatically when set to build for production) which would ensure that the application running on the client side would piece together fitting pieces of the whole.
When a browser is running the old application version and it still makes a call to the server for a specific chunk, which now has the new version deployed, the requested chunk could not be correctly identified anymore by their resource URI consisting of the name and a specific hash. The server sees the requested resources and deducts that it can’t match sending a 404 (or 410) back. The client, awaiting that piece of the application to continue, now stalls or crashes. In an Angular project you would probably see something similar as this next picture appear in the console log:
UX-wise this is not favourable as the user is left guessing about what just happened it might even cause some frustration. Eventually the user might do a refresh and the new version is fetched and bootstrapped (minding we have set a cache-busting explored in previous paragraph). Circumventing this user experience, we could intervene to handle a failing request for a new chunk. It requires a bit of code, but if we are able to hook into the process of fetching chunks we would be able to mitigate the 404 (or 410) and, for example, do a window refresh in the background or show a pop-up dialog for the user. A (silent) window refresh would then only result in an extra delay for the user while routing which might not even be really noticeable. Handling it this way would feel for the user as a small hiccup but the application itself wouldn’t stall or break when trying to fetch a chunk that has exceeded its’ shelf life. However, in memory states will be lost while doing a window refresh and should be considered - if there is no way to circumvent important state loss a pop-up dialog might at least inform the user. For hooking into the Lazy Loading of chunks, depending on the framework of the SPA it might differ on the technique used.
Solving merely for loading chunks is not enough to cover version updates for active sessions, however. The second scenario for active sessions would be when users won’t require any more chunks to cover their usage of the application or they wouldn’t fetch a new chunk ‘on time’ and continue to use APIs that are updated or outdated. For this scenario there needs to be some mechanism in place that forces (or hints to) the active user to refresh the application. This could be done in a couple of ways. For example, having versioned APIs or sending the version with each request in the header which the backend would validate before anything else. A downside of this approach is that now the updates of the FE API's require also an update in the BE and thereby introduces a layer of coupling. A polling mechanism, or a mechanism alike, on the client side would also work. This would mitigate the mentioned coupling but here the downside would be that it would provide for a small window of time in which the client can make API calls that have gone outdated.
Another idea worth mentioning are websocket connections that ping the clients with each new version update. This, however, would require an extra layer of effort to make sure that each client can not ignore the ping. While websockets use TCP and no message should get lost in the application layer – you might need to be more strict and cover for application crashes too. Broadly, in the mechanism of websockets you would have some sort polling mechanism on the backend to ensure all clients are on the newest version and didn’t ignore the ping.
Using any of the approaches, an outdated API on the client's side can eventually "caught" and be handled by informing the user (save & refresh) or forcing a refresh.
Check. This covered both scenarios for an active session.
Conclusion
Updating an SPA that is client side rendered, and giving a fluent as possible user experience, requires a little bit of configuration. For new sessions it hinges on the ‘Cache-Control’ configuration set by the server. For active sessions it would help to mitigate chunk load fails and steer a client towards version updates for example by using a versioned APIs.
To get one step further to improve the UX when dealing with a version update, each update might change the structure or expected behaviour of the application. A user might appreciate it to get a message or notification, or something, that indicates a version update. Users might start to understand that they could expect a change which could set their expectations of their current usage, especially when updating an active session prompting a notification might be appreciated. Setting this up can easily be done by storing the current version in local storage or a cookie and letting the application use that to validate the version first thing after bootstrap. The current version of the application can, for example, live as an environmental variable.
A quick glance at the backend
It is hard to make a generalization about the setup of the backend. But take the case that the backend is deployed on multiple pods and in front of these pods sits a load balancer that routes incoming traffic to specific available pods. New versions deployed in the backend will at first only be on a part of the total pods and then the process would continue to slowly phase out pods that are running the old version with pods that run the new version. During this roll out there is a window in which both versions are available. If API calls made by the frontend and are properly versioned it would be possible to route requests, through the load balancer, to a pod running that compatible version. If not versioned there exists a time window in which old frontend versions accidently communicate with newer backend versions or vice versa. Leading to unexpected behaviour.
Also, in the synchronization between frontend and backend there might be a design decision for backwards compatible API’s to support older frontend versions. This can both be done by versioning and not. When approached without versioning complexity increases.
But this is all for the glance on synchronizing the backend and the frontend.
For now, I hope this text in updating versions for SPA’s has given you the necessary insights needed to version update your insights. Happy coding.