Using a CloudFront API Proxy to Invalidate a Single-Page Application Without Polling

27 June 2022

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


img.png

Initial design

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.

img_1.png

Refresh needed, versions are not the same

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.

img_2.png

Refresh is not needed, versions are the same

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.

img_4.png

Same concept using CloudFront Response Headers Policies

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.

img_5.png

Timestamp used as the version number

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.

AWS Architect Profesional
AWS Developer Associate AWS Architect Associate
AWS Community Hero
Tags
Archive