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.
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 ownpackage.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 forShared 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 abootstrap.jsindirection: make the real entryimport('./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.
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.
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-Controlfrom 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,
singletonwill 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.
Related
- Monorepo and micro-frontend hashing — namespacing hashed output per package on one shared CDN origin
- Per-package vs global asset manifests — lookup cost, rollback granularity, and collision risk of merged manifests
- Stopping hash drift between Turborepo cache hits — proving a restored artifact is byte-identical to a local build
- Webpack output hashing setup —
contenthashtemplates, runtime chunks, and stable chunk IDs - Immutable Cache-Control and TTL tuning — choosing edge TTLs for hashed and unhashed files