CloudFront invalidation that fits a deploy pipeline
A client ships a Next.js app through GitHub Actions. The build step uploads hashed assets to S3, CloudFront sits in front, and after every deploy someone runs `aws cloudfront create-invalidation --paths "/*"`. That wildcard invalidation costs $0.005 per path after the first 1,000 free ones each month, and CloudFront counts `/*` as one path — so the cost is trivial. The real problem is that it invalidates everything at once, which means every user's next request misses the cache and hits the origin simultaneously. For a low-traffic admin tool this is fine. For a storefront doing 200 requests per second during a sale, it is not.
We stopped using wildcard invalidations for apps where the deploy frequency is high and the traffic is non-trivial. The strategy is narrow: invalidate only what actually changed, and let the content hash do the rest.
The build produces files with content hashes in their names — `app.a3f2b1.js`, `vendor.9c4d7e.js`, `chunk-2.8b1f0a.css`. These go to S3 under a `/static/` prefix with a one-year cache policy on CloudFront. Because the filename changes when the content changes, there is nothing to invalidate. The old files stay cached and serve stale requests from users who haven't refreshed the HTML yet. The new files are fresh in S3 and get cached on first request.
The HTML file is the one thing that needs invalidation. It references the new hashed assets by name, so if a user loads a stale HTML file, they get the old JS bundle — which still works because the old assets are still in S3. But we want the new HTML to reach users quickly. So the deploy pipeline invalidates exactly one path: `/index.html`.
Here is the invalidation step from a recent client's pipeline:
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
--paths "/index.html"That is the whole strategy for most SPA deploys. One path, no wildcard, no origin stampede.
For apps with server-side rendering or multiple routes, the list grows but stays bounded. A Next.js app with dynamic routes might need `/`, `/products`, `/products/[id]`, and a handful of others. We generate the invalidation list during the build by comparing the file manifest between the previous deploy and the current one. The comparison runs in a script that reads the S3 object list for the current deploy prefix, diffs it against the previous one, and writes the changed paths to a file the invalidation step reads.
The tradeoff: this approach assumes your HTML is served from CloudFront and that your asset filenames are content-hashed. If you are serving a WordPress site where CSS and JS filenames do not change between deploys, you do need to invalidate those files explicitly. In that case, track the specific files that changed and invalidate those paths — not `/*`.
One thing we do not do is rely on short TTLs instead of invalidation. Setting a 60-second TTL on HTML avoids invalidation entirely, but it means users see stale content for up to a minute after a deploy, and it increases origin load because CloudFront re-fetches every 60 seconds regardless of whether anything changed. For a team that deploys once a day, a one-minute staleness window is acceptable. For a team that deploys five times a day and needs to confirm a fix is live, it is not. Invalidation gives you control over exactly when the cache flips.
The 1,000 free invalidation paths per month cover a lot of deploys if you are invalidating one to five paths each time. A team deploying ten times a day with three paths per deploy uses 900 paths in a month. If you cross 1,000, the cost is $0.005 per path — which is cheaper than the engineering time spent arguing about whether to invalidate.
We set this up once per project and then it runs without attention. The deploy pipeline pushes to S3, invalidates the HTML and any changed non-hashed files, and the team moves on. No one opens the AWS console to manually invalidate, and no one files a ticket about stale assets.