A single-page application (SPA) needs to be refreshed after deployment to present the user with the new content. The importance of this is often not realised – or, if it is, it's implemented with long polling. A more efficient method for refreshing an SPA is to piggyback off your existing API calls.
SPAs don’t make full requests to load the files on each page action/navigation. This client-side rendering is great for performance, but we lose the ability to immediately update the user-cached version of the site with the new one. This means a new version is only fetched from the server when the user does a full-page refresh.
“Please refresh the web page, you have the old version.”
If you’ve ever uttered this phrase after a new deployment, then you’ve experienced this as well. There are many ways around it, but most involve polling. This is when your frontend makes API calls every few minutes to some endpoint to find out if it needs to pop up the “new version available” prompt. The user can then just click on the “refresh” button and the app does a window.location.reload(); to reload the page and get the latest version from the server.
All current methods involve polling and calling home. It might be an API Gateway backed by a Lambda function or something as simple as an S3 file that you make an HTTP GET request on (hopefully proxied through CloudFront with a second behaviour). The response of this would be the latest frontend version and, if it’s more than the current version, the prompt is shown. Some developers also use service workers (SW) to do this polling, calling home to an API or even just checking the ETag on the index.html file.
Polling is, however, a less than ideal solution. Why? Imagine having 10 000 users each doing version calls every five minutes. Not only is this wasteful, but it’s also server intensive. However, what if we mitigate this problem by piggybacking on our application that is already doing API calls to the backend?
We don’t want the backend to take ownership of the logic to determine when the frontend needs to be refreshed, since this would dictate that we deploy the backend whenever we deploy the frontend. Since we’re using CloudFront to proxy through to our backend API, we can just add an extra header called cloudfront-version to be forwarded to the API origin. The Lambda then proceeds to forward this header back to the caller by returning it in every response header. This allows the frontend to add headers to every backend API call, keeping the logic confined within our frontend project.
The last piece of the puzzle is to have an environment variable in the SPA called current-version. We set both these variables to the same value at deploy time. The class that does the backend API calls has an interceptor to look at every API call’s response headers and compare the returned cloudfront-version with the current-version. If the value of the cloudfront-version is more than the current-version, prompt for refresh.
After the user refreshes, their current-version will be the same as the cloudfront-version header returned from all API calls. In this steady state, the user will not be prompted.
The astute reader might have noticed that we don’t have to involve the backend in this process at all. We can use the newly released (in November 2021) CloudFront Response Headers Policies to return this custom header on every API call response.
This response header policy is still only applied to the /api/* behaviour, but now we don’t have to rely on our backend code to forward the header back in the response.
Special thanks to Jacques Millard for reviewing this post and suggesting the use of CloudFront Response Header Policies.
There is one problem though. The contents of the SPA hosted in the S3 bucket do not update at the exact same time at which the CloudFront behaviour sets the version header, so your bucket might still have current-version = 1 and your CloudFront cloudfront-version = 2. In this scenario, the prompt for refresh will still be visible even after you just refreshed until the bucket version is also 2, which will take place once the deployment is finished (± 10 minutes).
To prevent this, we use a timestamp instead of a Git Hash or incremented version number. We set the version to the timestamp of now() + 15 minutes. The comparison logic changes slightly on the frontend and we show the prompt if the versions are not the same cloudfront-version !== current-version and if the current time is more than the new version now() > cloudfront-version.
We added 15 minutes to the timestamp value used as the version for both the current-version and cloudfront-version so that the condition is only checked after the deployment is finished.
There you have it! If you found this post helpful, stay tuned – we have more tips and tricks heading your way soon. And if you’re a frontend or backend developer looking for an opportunity to showcase your skills in an all-star team, visit our Careers page.