Using a CloudFront API Proxy to Invalidate a Single-Page Application Without Polling
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 polling. A more efficient method for refreshing an SPA is to piggyback off your existing API calls.
Just hit refresh in the browser, you have the old version.
If you ever uttered the phrase above after a new deployment, then you have experienced this as well.
Originally published on the Swipe iX blog.
Current Polling methods
There are many ways around this, most involve polling.
This is when your frontend makes repeated API calls, say every 5 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?
Initial Solution: Piggybacking Off Backend API Calls
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.
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 response headers and compare the returned cloudfront-version
with the current-version
. If the value
of cloudfront-version
is more than the current-version
, then prompt for refresh else don’t do anything.
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.
Improved Solution: CloudFront Response Header Policy
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. Below is the little snippet of CDK code, it is the
cloudfront.ResponseHeadersPolicy
construct that makes this happen.
const apiResponseHeaderPolicy = new cloudfront.ResponseHeadersPolicy(this, name("api-resp-head-pol"), {
customHeadersBehavior: {
customHeaders: [{
header: "cloudfront-version",
value: props.cloudfrontVersion,
override: true,
}]
}
});
const frontendDist = new cloudfront.Distribution(this, name("web-dist"), {
comment: name("web-dist"),
defaultBehavior: {
origin: new origins.S3Origin(frontendBucket),
compress: true,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
},
additionalBehaviors: {
"/api/*": {
origin: new origins.HttpOrigin(buildProps.Params.ApiUrl),
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
compress: false,
responseHeadersPolicy: {
responseHeadersPolicyId: apiResponseHeaderPolicy.responseHeadersPolicyId
},
cachePolicy: new cloudfront.CachePolicy(this, name("api-proxy-policy"), {...}),
},
"/cdn/public/*": {
origin: new origins.S3Origin(backendCdnBucket),
compress: true,
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
}
},
errorResponses: [...],
domainNames: [buildProps.Params.DomainName],
certificate: cert.Certificate.fromCertificateArn( this, name("web-dist-cert"), buildProps.Params.WildCardCert),
defaultRootObject: "index.html"
});
Special thanks to Jacques Millard for reviewing this post and suggesting the use of CloudFront Response Header Policies.
One problem - Refresh Loop
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. 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.