Sharing Hashed Chunks Across Module Federation Remotes

Module Federation inverts the usual fingerprinting rule: every chunk a remote emits should be immutable for a year, but the one file that names those chunks — remoteEntry.js — must never be cached immutably, or hosts will keep loading last week’s chunk names until their edge cache expires.

The symptom

A team deploys a federated remote. CI is green, the new chunks are on the CDN, and the remote’s own standalone page shows the new build. But the host shell keeps rendering the old widget. Hard-refreshing the host does nothing. Some users see the new version, most do not, and the split follows no pattern anyone can reproduce.

The cause is almost always a single misapplied header. Somebody added Cache-Control: public, max-age=31536000, immutable to everything under the remote’s asset prefix, remoteEntry.js included. That file is the remote’s runtime manifest: it is the only artifact whose name is stable and whose content changes on every deploy. Marking it immutable tells every browser and every edge node that its contents can never change, so the host keeps executing a container that resolves ./Button to Button.4b1e0d55.js long after the remote started emitting Button.9a02f7c3.js.

Why the pointer must expire

A federated host does not import a remote at build time. It emits a call into the Module Federation runtime, which at page load fetches remoteEntry.js from the remote’s origin, evaluates it, and reads the chunk map it defines. Only then does it know which hashed files to request. That is one level of indirection more than a conventional bundle, and it is exactly where the caching asymmetry lives.

Everything downstream of remoteEntry.js carries a content hash in its filename and can safely be frozen forever, the same way any other fingerprinted output from Webpack can. remoteEntry.js itself carries no hash, so its freshness has to come from the transport layer. The two headers you want are:

# remoteEntry.js — the pointer. Short TTL, always revalidated.
Cache-Control: public, max-age=60, must-revalidate
ETag: "b4f1c9e2a70d"

# Every hashed chunk it points at — frozen.
Cache-Control: public, max-age=31536000, immutable

must-revalidate matters more than the max-age number. It forbids an edge or browser cache from serving the object once it is stale, even under load or when the origin is briefly unreachable. Without it, a CDN configured for stale-if-error can happily serve a two-day-old container during an origin blip and pin every host on that traffic to a chunk set that may no longer exist.

The federation pointer and its immutable targets A host shell fetches remoteEntry.js, which is served with a sixty-second must-revalidate policy. That file names three content-hashed chunks, each served with a one-year immutable policy. Two cache policies, one dependency graph Host shell deployed separately runtime fetch remoteEntry.js max-age=60, must-revalidate NOT immutable remote_ui.7f3a9c21.js immutable, one year Button.4b1e0d55.js immutable, one year vendors-react.9c2f81ab.js immutable, one year The pointer must expire; everything the pointer names never has to.
The host resolves a remote through one unhashed file, so that file is the only place freshness can be enforced.

Serving the split policy

On Nginx, match the exact filename before the generic hashed-asset rule, because location matching is order-sensitive for regex blocks:

server {
    listen 443 ssl;
    server_name cdn.example.com;
    root /srv/microfrontends;

    # The pointer: revalidate on every use once stale.
    location ~* /remoteEntry\.js$ {
        add_header Cache-Control "public, max-age=60, must-revalidate" always;
        etag on;
    }

    # Everything carrying an 8-hex content hash: frozen.
    location ~* \.[0-9a-f]{8}\.(js|css|woff2|png|svg)$ {
        add_header Cache-Control "public, max-age=31536000, immutable" always;
    }

    location / {
        try_files $uri $uri/ =404;
    }
}

On Cloudflare, express the same split as two Cache Rules ordered pointer-first: a rule matching ends_with(http.request.uri.path, "/remoteEntry.js") with Edge TTL 60 s and origin cache control respected, then a rule matching the hashed-filename pattern with Edge TTL set to a year. On CloudFront, use two cache behaviours with distinct path patterns and a response headers policy per behaviour; the broader discussion of picking those numbers lives in the immutable TTL tuning reference.

Three ways to name the pointer

You have three realistic options for remoteEntry, and they trade cacheability against deploy independence.

Approach Cacheability Independent deploy Rollback Extra request Failure mode
Unhashed remoteEntry.js, short TTL Poor — revalidated every 60 s Full Redeploy the remote; hosts pick it up within one TTL None A misapplied immutable header freezes every host until the edge object expires
Versioned remoteEntry.1.14.0.js + unhashed manifest.json pointer Pointer revalidates; entry file is immutable Full Rewrite one JSON key to the previous version One extra round trip before the container loads Pointer and entry can disagree if the JSON is written before the entry uploads
Content-hashed remoteEntry.<hash>.js baked into the host Perfect — everything immutable None; the host must rebuild and redeploy Roll back the host, not the remote None Remote deploys become invisible until the host ships, which defeats the point of federation

The default is the first row. Reach for the second when your edge cannot be trusted to honour a short TTL (some corporate proxies ignore must-revalidate) or when you need the pointer swap to be the atomic commit point of a deploy, matching the asset-then-HTML ordering used in the CI/CD asset pipeline. The third row is not federation in any useful sense — it is a build-time import with extra ceremony.

If you take the second option, the pointer file is a two-key manifest and belongs to the same family as the artifacts described under asset manifest generation:

{
  "name": "checkout",
  "remoteEntry": "/mf/checkout/remoteEntry.1.14.0.js",
  "builtAt": "2026-07-29T11:02:44Z",
  "commit": "4c9a1f0"
}

Shared dependencies and version negotiation

The second failure class is duplication. Two remotes each bundle their own React, the host bundles a third, and a user downloads 130 KB of framework three times — or worse, two React copies initialise and hooks throw Invalid hook call because the components are wired to different module instances.

shared is the mechanism that prevents this, and its four relevant fields behave as follows.

  • singleton: true — at most one instance of the module may exist in the whole page. The runtime picks one copy and every consumer binds to it. Required for React, react-dom, and any state library that relies on module-level identity.
  • requiredVersion — the semver range this consumer needs. Defaults to the version range in the consumer’s own package.json. If the winning copy does not satisfy it, you get a console warning naming the mismatch.
  • strictVersion: true — upgrades that warning to a thrown error. Useful in staging, dangerous in production: a minor drift between two independently deployed remotes will take the page down rather than degrade it.
  • eager: true — inlines the shared module into the initial chunk instead of loading it asynchronously. This removes the negotiation entirely for that module and is the standard fix for Shared module is not available for eager consumption, which means a shared module was requested synchronously from an entry that was never wrapped in an async boundary. The cleaner fix is a bootstrap.js indirection: make the real entry import('./bootstrap') so Webpack has somewhere to insert the async gate.

At runtime the container negotiates: among all registered copies of a shared module that satisfy the consumer’s requiredVersion, the highest version wins, and singletons force everyone onto that one copy. A remote whose own React chunk loses the negotiation still shipped that chunk to the CDN — it simply never gets requested, which costs storage but no bandwidth.

Shared-dependency resolution A decision tree starting from a host requesting a shared module, branching on singleton, requiredVersion satisfaction, and strictVersion, and ending in reuse, duplicate load, warning, or a thrown error. Shared-dependency version negotiation at runtime Remote requests shared singleton: true? no Load its own copy extra bundle download yes requiredVersion satisfied? yes Reuse host copy zero extra bytes no strictVersion: true? yes Throw at runtime Unsatisfied version no Warn, load own copy version mismatch logged eager: true inlines the module into the initial chunk and skips this negotiation entirely.
Only the leftmost path avoids shipping a second copy of the framework to the browser.

A remote and a host, end to end

The remote exposes components and publishes an unhashed container next to hashed chunks. Note that uniqueName must be globally distinct — two remotes sharing a uniqueName collide on Webpack’s runtime globals and one silently overwrites the other’s chunk-loading function.

// packages/checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    // Absolute so the host loads chunks from the remote's own origin,
    // not from whatever domain the host happens to be served on.
    publicPath: 'https://cdn.example.com/mf/checkout/',
    uniqueName: 'checkout',
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].js',
    assetModuleFilename: 'media/[name].[contenthash:8][ext]',
    clean: false,
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'checkout',
      // Deliberately unhashed: this is the pointer.
      filename: 'remoteEntry.js',
      exposes: {
        './CartWidget': './src/CartWidget.jsx',
        './PriceLabel': './src/PriceLabel.jsx',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,
          strictVersion: false,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
          strictVersion: false,
        },
      },
    }),
  ],
};

The host names the remote by URL. Because the URL has no hash in it, the host never needs redeploying when the remote ships:

// apps/shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    publicPath: 'https://cdn.example.com/mf/shell/',
    uniqueName: 'shell',
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].js',
    clean: false,
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        checkout: 'checkout@https://cdn.example.com/mf/checkout/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react, eager: true },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'], eager: true },
      },
    }),
  ],
};

Eight hex characters is the right default for [contenthash:8]. Raise it to 12 or 16 once a monorepo emits thousands of chunks across many packages sharing one origin, where birthday-bound math starts to matter — the reasoning is worked through in preventing hash collisions in large frontend projects.

@module-federation/enhanced replaces the built-in plugin with a mf-manifest.json sidecar and richer runtime plugins, but the header split is identical: the manifest and remoteEntry.js revalidate, the hashed chunks freeze. Vite’s @originjs/vite-plugin-federation emits remoteEntry.js as a real ES module rather than a script container, which changes the fetch mode but not one thing about the caching rule.

Retention: the deploy that breaks yesterday’s hosts

The most damaging Module Federation incident is not a stale container — it is a fresh container pointing at files that were deleted.

Picture a browser that loaded the host at 09:58 and cached remoteEntry.js for 60 s. The remote redeploys at 10:00 and CI runs aws s3 sync dist/ s3://bucket/mf/checkout/ --delete. Every superseded hashed chunk is gone within seconds. That browser now navigates into the cart route, the runtime asks for Button.4b1e0d55.js — a name it read from its still-valid cached container — and gets a 403 or 404. The user sees a blank panel. Nothing in the remote’s logs shows a problem, because the remote’s own build is perfectly healthy.

The fix is retention, not caching. Old hashed chunks must survive for at least the remoteEntry TTL plus the longest realistic session, because a single-page host may hold a loaded container in memory for hours without ever re-fetching it. In practice: drop --delete from the deploy sync entirely and run a separate lifecycle job that removes objects older than 30 days, or use an S3 lifecycle rule keyed on prefix.

Retention overlap after a remote redeploy A timeline from ten minutes before a redeploy to two hours after, showing that old chunks must remain on the origin past the point where every cached remoteEntry has expired, otherwise a 404 window opens. Retention overlap after an independent remote deploy T0 redeploy Old chunks on origin kept 30 min past T0 New chunks on origin uploaded before the flip Cached remoteEntry TTL 60s, then revalidate 404 risk window closes only if old chunks are retained T0-10m T0 T0+60s T0+30m T0+2h Keep every superseded chunk for at least the remoteEntry TTL plus your longest session.
A long-lived single-page host can hold an evaluated container far past its TTL, so retention has to outlast the cache, not merely match it.

Verification

Two commands settle almost every federation caching argument. The first proves the split policy is actually in force:

# The pointer must revalidate.
curl -sI https://cdn.example.com/mf/checkout/remoteEntry.js \
  | grep -iE '^(cache-control|etag|age):'
# cache-control: public, max-age=60, must-revalidate
# etag: "b4f1c9e2a70d"

# A chunk it names must be frozen.
curl -sI https://cdn.example.com/mf/checkout/Button.4b1e0d55.js \
  | grep -i '^cache-control:'
# cache-control: public, max-age=31536000, immutable

The second proves which chunk names the container currently in front of users actually resolves to. Fetch it exactly as a browser would and extract the hashed filenames the chunk map contains:

curl -s https://cdn.example.com/mf/checkout/remoteEntry.js \
  | grep -oE '"[A-Za-z0-9_.-]+\.[0-9a-f]{8}\.js"' \
  | tr -d '"' | sort -u

# Then confirm every one of them is still reachable:
for f in $(curl -s https://cdn.example.com/mf/checkout/remoteEntry.js \
             | grep -oE '"[A-Za-z0-9_.-]+\.[0-9a-f]{8}\.js"' | tr -d '"' | sort -u); do
  code=$(curl -s -o /dev/null -w '%{http_code}' \
         "https://cdn.example.com/mf/checkout/$f")
  echo "$code  $f"
done

Any line that is not 200 is a chunk the container promises and the origin no longer has — the retention bug, caught before a user finds it. Run this loop as a post-deploy step alongside the other manifest checks in your pipeline.

In DevTools, the equivalent check is the Network panel filtered to the remote’s origin: remoteEntry.js should show a 200 with a fresh age on a cold load and a 304 on reload, while every hashed chunk beside it shows (disk cache) or (memory cache). A remoteEntry.js served from disk cache after a deploy is the bug at the top of this page.

When to reconsider

Runtime federation buys independent deploys and costs you a runtime indirection, a shared-version contract, and a retention policy. Skip it when:

  • All the apps ship together anyway. If release trains are lockstep, a plain workspace dependency and a build-time import give you one hash graph, no negotiation, no pointer to keep fresh, and hashes that only change when the code does. The per-package output layout in the monorepo hashing guide handles the collision problem without any of this.
  • There are only two or three apps. The operational surface of federation — pointer TTLs, singleton drift, retention windows, per-remote rollback — is fixed cost that only amortises across many independently owned teams.
  • You cannot control CDN headers per path. Without the ability to give one file a different Cache-Control from its neighbours, federation is unsafe by construction.
  • Your shared surface is unstable. If React, the router, and the design system all move on independent schedules, singleton will fight you constantly. Stabilise versions first, federate second.

Where you do federate, keep the per-remote asset layout and manifest strategy consistent across packages; the tradeoffs are laid out in per-package vs global asset manifests.