Photo by Brett Sayles on Pexels
To run a CloudFront invalidation with AWS SDK v3, send CreateInvalidationCommand from @aws-sdk/client-cloudfront with the DistributionId and an InvalidationBatch: a unique CallerReference and Paths with Quantity and Items such as /index.html or /css/*. Then call waitUntilInvalidationCompleted if your script must wait for the status to reach Completed.
This guide is for Node.js and TypeScript developers who deploy static sites or assets behind CloudFront and need the edge caches to pick up new files. You’ll get the basic call, the path rules that decide whether an invalidation actually removes anything, a reusable module that is safe to retry, a deploy script that invalidates only what changed, the cost, and the IAM policy.
Every sample was type-checked with strict tsc against @aws-sdk/client-cloudfront 3.1142.0 and run in September 2026 against mocked CloudFront and S3 clients. If you’re on SDK v2, cloudfront.createInvalidation(params).promise() becomes client.send(new CreateInvalidationCommand(params)); the free AWS SDK v2 to v3 converter handles whole files.
How does CreateInvalidation work in AWS SDK v3?
| Field | What to send or expect |
|---|---|
DistributionId |
The distribution ID, for example E2QWRUHAPOMQZL, not its domain name. |
InvalidationBatch.Paths |
Items is the list of paths; Quantity must equal its length or CloudFront returns InconsistentQuantities. |
InvalidationBatch.CallerReference |
A unique value per invalidation. Resending the same value with the same paths returns the existing invalidation instead of creating a new one; the same value with different paths fails with InvalidationBatchAlreadyExists. |
Response Invalidation |
Id, Status (InProgress, later Completed), CreateTime and the batch you sent. |
CloudFront forwards the request to every edge location within a few seconds, and the developer guide is explicit that you can’t cancel an invalidation once it’s submitted. waitUntilInvalidationCompleted polls GetInvalidation; in SDK 3.1142.0 its default delay starts at 20 seconds and backs off to 600.
Prerequisites
- Node.js 18 or later, a project with
"type": "module"(the samples use top-levelawait),@aws-sdk/client-cloudfrontandtsx. The deploy example also needs@aws-sdk/client-s3. - Credentials the SDK can find; AWS SDK v3 credential providers covers profiles, SSO and CI roles.
- The distribution ID. If the site itself doesn’t resolve, fix that first with the script to troubleshoot a Route 53 domain and CloudFront distribution.
How to run a CloudFront invalidation with AWS SDK v3, step by step
- Create the client
new CloudFrontClient({ region: "us-east-1" }). CloudFront is a global service, andus-east-1is the conventional Region for its client. - Build the pathsEach path starts with
/, is case sensitive, and may end with a single*wildcard. URL-encode spaces and non-ASCII characters. - Pick a CallerReferenceUse a value tied to the change, such as a deploy ID plus a hash of the paths, so a retried deploy reuses it.
- Send CreateInvalidationCommandKeep the returned
Invalidation.Id. - Wait if you need toCall
waitUntilInvalidationCompletedwith amaxWaitTimein seconds, or return straight away and let the invalidation finish on its own.
Example: invalidate paths and wait
// invalidate.ts: invalidate two paths on a CloudFront distribution and wait until it has completed.
// Usage: DISTRIBUTION_ID=E2QWRUHAPOMQZL npx tsx invalidate.ts
import { CloudFrontClient, CreateInvalidationCommand, waitUntilInvalidationCompleted } from "@aws-sdk/client-cloudfront";
const client = new CloudFrontClient({ region: "us-east-1" }); // CloudFront is global; any Region works
const DistributionId = process.env.DISTRIBUTION_ID ?? "E2QWRUHAPOMQZL";
const paths = ["/index.html", "/css/*"]; // API paths must start with "/"; "*" only at the end
const { Invalidation } = await client.send(new CreateInvalidationCommand({
DistributionId,
InvalidationBatch: {
CallerReference: `deploy-${Date.now()}`, // new value for every new invalidation
Paths: { Quantity: paths.length, Items: paths },
},
}));
console.log(`Invalidation ${Invalidation?.Id} is ${Invalidation?.Status}`);
await waitUntilInvalidationCompleted(
{ client, maxWaitTime: 900 }, // seconds; polls GetInvalidation every 20 s or more
{ DistributionId, Id: Invalidation?.Id ?? "" },
);
console.log(`Invalidation ${Invalidation?.Id} is Completed`);
Invalidation IX2QXVKEXAMPLE is InProgress
Invalidation IX2QXVKEXAMPLE is Completed
The output comes from a run against a mocked client, so the ID is illustrative. waitUntilInvalidationCompleted throws when maxWaitTime runs out, which is what you want in CI: a stuck invalidation fails the job instead of hanging it.
Which paths should you invalidate?
An invalidation that doesn’t match the cached URL succeeds, gets billed, and removes nothing. The CloudFront developer guide sets these rules:
- Leading slash. With the API and SDKs every path must start with
/; only the console lets you omit it. - Wildcards at the end only.
*matches zero or more characters and must be the last character./images/*covers everything under/images/; an asterisk anywhere else is a literal character./*invalidates the whole distribution. - Case and query strings. Paths are case sensitive. If the cache key includes query strings, invalidate each variant or end the path with
*, for example/images/image.jpg*. - Encoding. URL-encode non-ASCII and unsafe characters and nothing else, or the old file stays cached. The
~character isn’t supported in invalidation paths at all. The maximum path length is 4,000 characters. - Directories. If your site serves both
/docsand/docs/, invalidate both forms. The default root object is invalidated like any other file, so a changed home page usually needs/and/index.html. - Cache tags. Distributions with a
CacheTagConfigcan invalidate by tag: an item starting with#, such as#product:electronics, removes every cached object carrying that tag. Distributions without the configuration ignore tags.
Example: a reusable, retry-safe invalidation module
Deploy pipelines retry. Without care, each retry creates and bills a new invalidation. This module derives CallerReference from a deploy ID plus a hash of the normalized paths, so a retry of the same deploy gets the existing invalidation back. It also collapses long lists into /* and backs off when CloudFront returns TooManyInvalidationsInProgress.
// cloudfront-invalidate.ts: normalize paths, create an idempotent invalidation, retry when too many
// invalidations are in progress, and optionally wait for it to complete.
import { createHash } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import {
CloudFrontClient,
CreateInvalidationCommand,
TooManyInvalidationsInProgress,
waitUntilInvalidationCompleted,
} from "@aws-sdk/client-cloudfront";
export interface InvalidateOptions {
/** Stable ID for this logical change, such as a deploy ID or commit SHA. Retries reuse it. */
reference: string;
/** Collapse to one wildcard path when there are more than this many paths (each path is billed). */
maxPaths?: number;
/** Seconds to wait for completion; 0 returns as soon as the invalidation is created. */
waitSeconds?: number;
}
/** Turn object keys or URL paths into CloudFront invalidation paths. */
export function toInvalidationPaths(keysOrPaths: string[]): string[] {
const out = new Set<string>();
for (const raw of keysOrPaths) {
if (raw.startsWith("#")) { // cache tag invalidation; needs CacheTagConfig on the distribution
out.add(raw);
continue;
}
if (raw.includes("~")) throw new Error(`CloudFront can't invalidate paths containing "~": ${raw}`);
const path = raw.startsWith("/") ? raw : `/${raw}`;
out.add(encodeURI(path)); // encodes spaces and non-ASCII characters; keeps "/", "*" and "?"
}
return [...out].sort();
}
export async function invalidate(
client: CloudFrontClient,
distributionId: string,
keysOrPaths: string[],
opts: InvalidateOptions,
): Promise<{ id: string; status: string; paths: string[] }> {
let paths = toInvalidationPaths(keysOrPaths);
if (paths.length === 0) throw new Error("nothing to invalidate");
if (opts.maxPaths !== undefined && paths.length > opts.maxPaths) paths = ["/*"];
// Same reference + same paths = same CallerReference, so a retried deploy returns the
// existing invalidation instead of creating (and paying for) a second one.
const digest = createHash("sha256").update(paths.join("\n")).digest("hex").slice(0, 16);
const CallerReference = `${opts.reference}-${digest}`;
for (let attempt = 1; ; attempt++) {
try {
const { Invalidation } = await client.send(new CreateInvalidationCommand({
DistributionId: distributionId,
InvalidationBatch: { CallerReference, Paths: { Quantity: paths.length, Items: paths } },
}));
const id = Invalidation?.Id ?? "";
if (opts.waitSeconds) {
await waitUntilInvalidationCompleted({ client, maxWaitTime: opts.waitSeconds }, { DistributionId: distributionId, Id: id });
return { id, status: "Completed", paths };
}
return { id, status: Invalidation?.Status ?? "", paths };
} catch (err) {
if (!(err instanceof TooManyInvalidationsInProgress) || attempt >= 5) throw err;
const delay = 2 ** attempt * 1000 + Math.floor(Math.random() * 1000);
console.warn(`too many invalidations in progress, retrying in ${Math.round(delay / 1000)} s`);
await sleep(delay);
}
}
}
encodeURI is a deliberate choice: it encodes spaces and non-ASCII characters but leaves /, * and ? alone, which matches the rule above. Pass raw object keys, not keys that are already percent-encoded, or they’ll be encoded twice. The SDK’s own retries (see AWS SDK v3 retries and timeouts) cover throttling and network errors, but TooManyInvalidationsInProgress isn’t in the SDK’s list of throttling error codes, so it isn’t retried automatically; that’s why the loop exists.
Example: invalidate after deploying a static site to S3
The cheapest invalidation is the one you don’t need. The developer guide recommends versioned file names for files that change often: the browser and every proxy fetch the new name, and nothing has to be purged. This deploy script uploads a build folder, gives fingerprinted assets like app.3f9c2b1e.js a one-year Cache-Control, and invalidates only the files whose names never change: HTML, robots.txt and the like.
// deploy-site.ts: upload a built static site to S3, then invalidate only the files whose names
// don't change between builds. Fingerprinted assets (app.3f9c2b1e.js) get a long cache lifetime
// and are never invalidated.
// Usage: BUCKET=www.example.com DISTRIBUTION_ID=E2QWRUHAPOMQZL DEPLOY_ID=$(git rev-parse --short HEAD) npx tsx deploy-site.ts ./dist
import { readdir, readFile } from "node:fs/promises";
import { extname, join, relative, sep } from "node:path";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { CloudFrontClient } from "@aws-sdk/client-cloudfront";
import { invalidate } from "./cloudfront-invalidate.js";
const root = process.argv[2] ?? "./dist";
const bucket = process.env.BUCKET ?? "www.example.com";
const distributionId = process.env.DISTRIBUTION_ID ?? "E2QWRUHAPOMQZL";
const deployId = process.env.DEPLOY_ID ?? `local-${Date.now()}`;
const TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript",
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png", ".txt": "text/plain",
".xml": "application/xml", ".woff2": "font/woff2",
};
const FINGERPRINTED = /\.[0-9a-f]{8,}\.[a-z0-9]+$/i; // app.3f9c2b1e.js, logo.a1b2c3d4e5.svg
async function* walk(dir: string): AsyncGenerator<string> {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else yield full;
}
}
const s3 = new S3Client({ region: process.env.AWS_REGION ?? "us-east-1" });
const toInvalidate: string[] = [];
let uploaded = 0;
for await (const file of walk(root)) {
const key = relative(root, file).split(sep).join("/");
const hashed = FINGERPRINTED.test(key);
await s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: await readFile(file),
ContentType: TYPES[extname(key).toLowerCase()] ?? "application/octet-stream",
CacheControl: hashed ? "public, max-age=31536000, immutable" : "public, max-age=60",
}));
uploaded += 1;
if (!hashed) toInvalidate.push(key);
}
// "index.html" is also served as "/", so invalidate the directory form too.
const paths = toInvalidate.flatMap((k) => (k === "index.html" ? ["/", k] : k.endsWith("/index.html") ? [`/${k.slice(0, -10)}`, k] : [k]));
console.log(`uploaded ${uploaded} files; ${paths.length} paths need invalidating`);
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const result = await invalidate(cloudfront, distributionId, paths, { reference: deployId, maxPaths: 50, waitSeconds: 900 });
console.log(`invalidation ${result.id} ${result.status}: ${result.paths.join(" ")}`);
uploaded 7 files; 6 paths need invalidating
invalidation IX2QXVKEXAMPLE Completed: / /docs/ /docs/caf%C3%A9%20menu.html /docs/index.html /index.html /robots.txt
The run used a sample dist folder and mocked clients. Of 7 files, the 3 fingerprinted assets were skipped, and the file named café menu.html was encoded to caf%C3%A9%20menu.html. The mock follows the documented CallerReference behavior, so a second run with the same DEPLOY_ID got the existing invalidation back; a mocked TooManyInvalidationsInProgress on the first attempt was retried after about 2 seconds. max-age and immutable are standard HTTP caching directives; RFC 9111, HTTP Caching defines how shared caches like CloudFront and browsers apply freshness. For the upload side, see uploading a file to S3 with S3Client in TypeScript and, for large build artifacts, uploading large files and streams to S3.
Tip: invalidating CloudFront doesn’t clear browser caches. A file served with max-age=31536000 under a fixed name stays in visitors’ browsers for a year whatever you invalidate, which is the strongest reason to fingerprint anything with a long lifetime.
What does a CloudFront invalidation cost?
The developer guide says the first 1,000 invalidation paths you submit each month are free, counted across all distributions in the account, and every path after that is charged. A wildcard path counts as one path however many files it removes, and each tag counts as one path too. As of September 2026, the AWS Price List for Amazon CloudFront (published 16 September 2026) shows:
| Invalidation paths per month (per account) | Price |
|---|---|
| First 1,000 | $0.00 |
| Over 1,000 | $0.005 per path |
Worked example: 3 sites deploy 60 times a month in total, each deploy listing 25 changed files. That’s 60 × 25 = 1,500 paths, 500 over the free allowance, so 500 × $0.005 = $2.50. The same deploys sending one /* path each use 60 paths and cost nothing, but they also evict every cached object and send a burst of requests to the origin. The deploy script’s approach, fingerprinted assets plus a handful of fixed-name paths, usually keeps you inside the free 1,000 without a full purge.
Which IAM permissions are needed?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvalidateOneDistribution",
"Effect": "Allow",
"Action": [
"cloudfront:CreateInvalidation",
"cloudfront:GetInvalidation",
"cloudfront:ListInvalidations"
],
"Resource": "arn:aws:cloudfront::123456789012:distribution/E2QWRUHAPOMQZL"
},
{
"Sid": "UploadSiteFiles",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::www.example.com/*"
}
]
}
CloudFront distribution ARNs have no Region, hence the empty field between the colons. GetInvalidation is what the waiter calls; drop it if you never wait. Scope the policy to the distributions a pipeline owns, and check your own code with the IAM policy generator for TypeScript AWS SDK code.
Troubleshooting and limits
| Error or symptom | Usual cause |
|---|---|
InconsistentQuantities |
Quantity doesn’t equal Items.length. Always set it from the array. |
InvalidationBatchAlreadyExists |
A reused CallerReference with different paths. Include a hash of the paths in the reference. |
TooManyInvalidationsInProgress |
Too many invalidation requests or objects in progress. Back off and retry, or merge paths into one request. |
BatchTooLarge |
The batch is too large; split it or use wildcards. |
NoSuchDistribution |
Wrong ID, or the domain name was passed instead of the ID. |
AccessDenied |
Missing cloudfront:CreateInvalidation on that distribution ARN; see how to troubleshoot AWS IAM access denied errors. |
Completed, but old content still shows |
The path didn’t match the cached URL (case, missing query string, encoding, trailing slash), or the browser cached it. |
The CloudFront quotas page lists 150 paths or tags per second and 1 wildcard invalidation per second, with no cap on how many files one wildcard can remove. You can’t cancel an invalidation, so a wrong /* during peak traffic can’t be undone. To unit test code like the module above, mock the AWS SDK v3 CloudFront client with aws-sdk-client-mock, which is how these samples were run.
Frequently asked questions
How do I invalidate all files in CloudFront with AWS SDK v3?
Send CreateInvalidationCommand with Paths: { Quantity: 1, Items: ["/*"] }. It counts as a single path for billing but removes every cached object, so the origin gets the full load until the cache refills.
Is CallerReference required?
Yes. It must be new for each new invalidation. Reusing it with identical paths returns the earlier invalidation, which makes retries safe; reusing it with different paths returns InvalidationBatchAlreadyExists.
How much does a CloudFront invalidation cost?
The first 1,000 paths each month are free across the account, then $0.005 per path in the September 2026 Price List. A wildcard or a cache tag counts as one path.
Can I cancel a CloudFront invalidation?
No. CloudFront sends it to all edge locations within seconds and starts processing immediately, so it can’t be cancelled.
Should I invalidate or use versioned file names?
For files that change often, AWS recommends versioned names: they also bypass browser and proxy caches and cost nothing to “invalidate”. Keep invalidations for fixed-name files like HTML.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud
