Anthony Wentzel

Wait for the CloudFront invalidation: deploying a SPA to S3

Deploying React apps to S3 and CloudFront, one lesson at a time. The most useful one: wait for the CloudFront invalidation to complete.

From 2018 · 2 min read · Published August 29, 2026

From 2017 through 2018 I shipped several React single-page apps the same way: build static files, copy them to an S3 bucket, and serve them through CloudFront, with a CloudFront invalidation on every deploy. A partner portal, a health platform's web app, and its rewrite all used some version of a small s3-deploy.sh.

Reading those scripts in order is a short course in what goes wrong.

Version one: copy and hope

The earliest version was two commands:

bin/s3-deploy.sh (2017)
set -o errexit
 
npm run build
aws s3 cp build/ s3://$BUCKET/ --recursive --acl public-read

It works, in the sense that the files land in the bucket. But CloudFront caches aggressively, so users kept getting the old index.html, which pointed to old JavaScript bundles. Deploys could look like they did nothing.

Version two: CloudFront invalidation

By late 2017 the scripts added a CloudFront invalidation after the upload. That fixed stale content, mostly. The catch is that create-invalidation returns immediately. The invalidation itself takes a while to propagate, and the CI job reports success before users can see the change.

Version three: wait for it

The 2018 version, after a burst of commits titled "fix: ci" that I won't pretend were elegant, landed here:

bin/s3-deploy.sh (2018, lightly reformatted)
set -o errexit
 
REACT_APP_ENV=$DEPLOY_ENV yarn build
yarn build-storybook
 
aws s3 cp build/ s3://$BUCKET/ --recursive --acl public-read
aws s3 cp storybook-static/ s3://$BUCKET/storybook/ --recursive --acl public-read
 
ID=$(aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --paths '/*' \
  | jq --raw-output '.Invalidation.Id')
aws cloudfront wait invalidation-completed --distribution-id $CLOUDFRONT_DISTRIBUTION_ID --id $ID 

Create the invalidation, capture its ID, and block until CloudFront says it's done. Now "deploy succeeded" means "users can see it." That's the only definition of success worth reporting.

Two smaller things in that script turned out to matter:

  • Storybook deploys next to the app at /storybook. Anyone with the link could see every component in every state without running anything locally.
  • The environment is a build input (REACT_APP_ENV). The same script deployed staging and production, with the difference coming from CI variables, not from branches of the script.

What I'd do now

Invalidate less. Hashed filenames mean bundles never need invalidating; only index.html does, and it can be served with a short cache lifetime. Platforms like Vercel handle all of this for you, which is one reason I stopped writing these scripts.


Related: Preview environments per pull request, before they were a checkbox · Yeoman generators: code conventions you can run