CDN Cache Invalidation for Federated Remotes #

When a CDN serves a stale remoteEntry.js after you deploy a remote, the host reads an old manifest that points at hashed chunks that no longer exist on the origin, and users get a ChunkLoadError until their cache expires.

This is the single most common production failure in Module Federation, and it is entirely a caching problem, not a bundler problem. The fix is to cache the two asset classes differently: the manifest (remoteEntry.js) must be short-lived or never cached, while the content-hashed chunks it references must be cached forever.

Prerequisites #

Stale remoteEntry vs immutable chunks at the edge A deploy publishes a new remoteEntry and new hashed chunks; the edge serves remoteEntry with a short TTL and chunks as immutable, while a purge call clears the manifest. Deploy build + upload CDN Edge remoteEntry.js short TTL / no-store hashed chunks immutable old chunks kept until 404 Purge API clears remoteEntry path Host fetches manifest on deploy
The deploy uploads a fresh manifest and chunks; the edge caches remoteEntry.js briefly and chunks forever, and a purge call clears only the manifest path.

Step 1 — Set the right Cache-Control headers per asset type #

Two rules cover almost every case. The manifest changes on every deploy and must be revalidated; the hashed chunks never change, so cache them for a year.

Cache policy per asset type Fingerprinted assets are cached forever and never purged; only the small unfingerprinted manifest and shell are revalidated. Asset Cache-Control Purge on deploy? remoteEntry.[hash].js public, max-age=31536000, immutable never chunk.[hash].js public, max-age=31536000, immutable never remotes.json manifest public, max-age=30, must-revalidate every deploy index.html (host shell) no-cache every deploy
Exactly one small file is allowed to be short-lived — everything a user actually downloads is immutable.
# remoteEntry.js — the manifest. Never trust a cached copy.
Cache-Control: no-store
# or, if you want a little edge caching with fast turnover:
Cache-Control: public, max-age=0, s-maxage=60, must-revalidate

# *.[contenthash].js / .css — the chunks. Cache forever.
Cache-Control: public, max-age=31536000, immutable

no-store is the safest setting for remoteEntry.js: the browser and the edge will always fetch a fresh copy. If your traffic is high enough that you want edge caching, use s-maxage=60 so only the CDN caches it, for at most a minute, and pair it with a purge (Step 4) so the window is effectively zero on deploy.

The immutable directive on chunks tells browsers to skip revalidation entirely, even on a hard reload, which is correct because the content hash is the version.

Step 2 — Configure the edge (nginx example) #

If you front your remote with nginx, match on filename shape, not on directory. The manifest is matched by exact name; chunks are matched by the hash segment.

# Manifest: always revalidate, never store a stale copy.
location = /remoteEntry.js {
    add_header Cache-Control "no-store" always;
    try_files $uri =404;
}

# Hashed assets: cache for a year, mark immutable.
location ~* "\.[a-f0-9]{8,}\.(js|css|woff2)$" {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    try_files $uri =404;
}

Step 3 — Or configure the CDN (Cloudflare Rules) #

On a managed CDN you express the same policy as cache rules. This Cloudflare Ruleset sets cache-control overrides keyed on the URI path.

{
  "rules": [
    {
      "expression": "(http.request.uri.path eq \"/remoteEntry.js\")",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "override_origin", "default": 30 },
        "browser_ttl": { "mode": "override_origin", "default": 0 }
      }
    },
    {
      "expression": "(http.request.uri.path matches \"\\.[a-f0-9]{8,}\\.(js|css|woff2)$\")",
      "action": "set_cache_settings",
      "action_parameters": {
        "cache": true,
        "edge_ttl": { "mode": "override_origin", "default": 31536000 }
      }
    }
  ]
}

Step 4 — Purge the manifest on every deploy #

Headers alone leave a TTL-sized window where the old manifest is still served. Close it by purging the remoteEntry.js path from your deploy pipeline, right after the upload completes and before you mark the deploy healthy.

# .github/workflows/deploy-remote.yml (excerpt)
- name: Upload assets to origin
  run: aws s3 sync ./dist s3://my-remote-bucket --cache-control "public, max-age=31536000, immutable" --exclude remoteEntry.js
- name: Upload manifest with no-store
  run: aws s3 cp ./dist/remoteEntry.js s3://my-remote-bucket/remoteEntry.js --cache-control "no-store"
- name: Purge manifest at the edge
  run: |
    curl -fsS -X POST \
      "https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
      -H "Authorization: Bearer ${CF_API_TOKEN}" \
      -H "Content-Type: application/json" \
      --data '{"files":["https://remote.example.com/remoteEntry.js"]}'

Upload chunks first, manifest second. That ordering guarantees that the moment the new manifest goes live, every chunk it references already exists on the origin. Never purge the hashed chunks — they are immutable and purging them only wastes cache.

Step 5 — Or use manifest indirection with a version #

If you would rather not depend on purge timing at all, give each deploy its own immutable folder and have the host read a tiny version pointer. The pointer is the only short-TTL file; everything under the versioned folder, including remoteEntry.js, becomes immutable. This pattern fits naturally with the approaches in Versioning Strategies for Remote Apps.

Manifest indirection keeps assets immutable One short-lived manifest points at long-lived fingerprinted assets, so a release or rollback changes a pointer rather than an asset. One mutable pointer, many immutable assets Host boots fetch /remotes.json 30 s TTL Manifest resolves orders -> v4.2.1 hashed URL Load remoteEntry immutable, year-long cache Rollback edit manifest only old asset still cached
The manifest is the only thing you ever purge, which is why invalidation stays fast and predictable.

Publish each build under /v/<gitSha>/ and write a version.json at a stable path:

{ "checkout": "/v/9f3c2a1/remoteEntry.js" }

Then load the remote at runtime from the version pointer instead of a hard-coded URL:

async function loadRemote(name) {
  // version.json is the ONLY short-TTL fetch in the whole flow.
  const map = await fetch('https://remote.example.com/version.json', {
    cache: 'no-store',
  }).then((r) => r.json());

  const url = new URL(map[name], 'https://remote.example.com').href;
  await import(/* webpackIgnore: true */ url); // loads remoteEntry.js
  const container = window[name];
  await container.init(__webpack_share_scopes__.default);
  const factory = await container.get('./App');
  return factory();
}

Because each remoteEntry.js lives under an immutable, sha-stamped path, two browser tabs can run two different deploys side by side without ever fighting over a cache entry. Rollback is just rewriting version.json to the previous sha.

Verification #

After deploying, confirm the headers actually arrived at the edge. Use curl -I and look at cache-control plus the CDN cache status header.

# Manifest must NOT be cached (or only briefly).
curl -sI https://remote.example.com/remoteEntry.js | grep -i 'cache-control\|cf-cache-status'
# expect: cache-control: no-store   (and cf-cache-status: DYNAMIC or MISS)

# A hashed chunk MUST be immutable.
curl -sI https://remote.example.com/src_App_tsx.a1b2c3d4.js | grep -i 'cache-control\|cf-cache-status'
# expect: cache-control: public, max-age=31536000, immutable   (cf-cache-status: HIT)

Confirm the purge took effect by requesting the manifest twice and checking that the body matches the freshly deployed build (compare the chunk filenames it references against ./dist). In the browser, open DevTools → Network, hard-reload, and verify remoteEntry.js returns 200 from the network while chunks return 200 (from disk cache) or 200 (memory cache). A ChunkLoadError in the console means the manifest is newer than the chunks the edge holds — re-check Step 4 ordering.

Troubleshooting #

Symptom: ChunkLoadError immediately after deploy, only for some users.

Diagnosis: the edge is serving a fresh remoteEntry.js (or the user fetched a fresh one) that references new chunks, but at least one POP still has the old chunks, or the chunk upload finished after the manifest. Fix: upload chunks before the manifest (Step 4), keep the previous deploy’s chunks on the origin for a grace period instead of deleting them, and verify the purge call returned success: true.

Symptom: immutable chunks return 404 after a deploy.

Diagnosis: your deploy deletes the old chunk files from the origin, but a stale cached remoteEntry.js still points at them. Fix: stop hard-deleting on deploy. Keep at least the last one or two builds’ chunks live, and make remoteEntry.js no-store (Step 1) so no stale manifest survives the cutover. The versioned-folder approach in Step 5 removes this failure mode entirely.

Symptom: mixed-version loads — the host runs new code but a remote behaves like the old build.

Diagnosis: the host cached remoteEntry.js in the browser longer than the chunks, so it initialized an old container against a new share scope. Fix: ensure remoteEntry.js carries no-store (not just s-maxage); browsers honor s-maxage only at shared caches, not locally, but a stale browser copy is the usual culprit. Reload the manifest with cache: 'no-store' as shown in Step 5.

Symptom: headers look right in curl but the browser still serves stale assets.

Diagnosis: edge POP inconsistency — your curl hit a different POP than the user’s browser, or the purge propagated unevenly. Fix: use a global purge (purge by URL, not by tag, for a single file) and wait for propagation before flipping traffic. For a hard guarantee, deploy under a new immutable path (Step 5) so there is no shared cache key to invalidate across POPs.

Why purging is the wrong default #

Reaching for a CDN purge on every deploy feels safe and is the source of most cache-related federation incidents. Three problems make it a poor primary mechanism.

A purge is not instant and it is not atomic. Edges revalidate at their own pace, so for a period measured in seconds to minutes some users receive the new asset and some the old one. If the two are not interchangeable — and a remote entry and its chunks are not — those users get a broken combination that neither version was tested as.

A purge is rate-limited. Every CDN caps how many objects you may invalidate and how often, and a federated estate deploying dozens of times a day will find that limit at the worst moment. Purging a whole path prefix to get under the object cap makes the propagation window longer, not shorter.

And a purge discards a warm cache. The next request from every region pays a full origin fetch, so a deploy intended to be invisible produces a latency spike for exactly the users the deploy was for.

Manifest indirection avoids all three because the only object that ever needs revalidating is a small JSON file with a short lifetime, and the assets it points at are immutable and never evicted. The deploy becomes a pointer change and the cache stays warm throughout.

Handling the chunks a manifest does not name #

The manifest names the entry; the entry names its chunks. That indirection is normally invisible and matters in one case: a user who loaded the page before a deploy and navigates afterwards, triggering a lazy chunk request against a version that has moved on.

If chunks are fingerprinted and never purged, the request succeeds — the old chunk is still there, still valid, and still matches the entry that asked for it. This is the strongest practical argument for never deleting old assets, and it is why a lifecycle rule that prunes objects after ninety days is more dangerous than it looks.

Where the chunk genuinely cannot be served, the failure is a ChunkLoadError, which is worth handling explicitly: catch it at the error boundary, and prompt a reload rather than showing a generic failure. The user’s page is simply out of date, and a reload fixes it.