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.

# 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.

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.