CloudFront Invalidation With AWS SDK v3 (CreateInvalidation)

Bright blue fiber optic cable ends glowing against a dark background

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

How to run a CloudFront invalidation with AWS SDK v3, step by step

  1. Create the clientnew CloudFrontClient({ region: "us-east-1" }). CloudFront is a global service, and us-east-1 is the conventional Region for its client.
  2. Build the pathsEach path starts with /, is case sensitive, and may end with a single * wildcard. URL-encode spaces and non-ASCII characters.
  3. 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.
  4. Send CreateInvalidationCommandKeep the returned Invalidation.Id.
  5. Wait if you need toCall waitUntilInvalidationCompleted with a maxWaitTime in seconds, or return straight away and let the invalidation finish on its own.

Example: invalidate paths and wait

invalidate.ts

// 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`);
Output

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 /docs and /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 CacheTagConfig can 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

// 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

// 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(" ")}`);
Output

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?

cloudfront-invalidation-policy.json

{
  "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