An app shell that composes independently deployed frontends has a failure mode monoliths do not: no single team owns the first paint. Each feature package looks reasonable in isolation, the shell loads twelve of them, and the login screen takes four seconds on a clinic laptop over a satellite link.

Where the weight actually comes from
In practice it is rarely the feature code. It is duplicated copies of things that were supposed to be shared:
- A package pinning its own
react-domminor, so two copies ship. - Design-system icons imported as a barrel, pulling the whole set.
- A date library bundled per package instead of resolved from the shell.
- Translation catalogues for every locale, loaded eagerly.
Making it measurable
The shell declares which dependencies it owns, and everything else has to treat them as external. With Module Federation that is one block:
new ModuleFederationPlugin({
name: "esmPatientChart",
filename: "remoteEntry.js",
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
"@openmrs/esm-framework": { singleton: true, import: false },
},
});
singleton: true makes a version mismatch a loud warning rather than a silent
second copy, and import: false means the package will not fall back to bundling
the framework when the shell fails to provide it — it fails instead, at build
time, where somebody will see it.
The budget itself
CI fails the build when a package's own output grows past its limit:
| Package kind | Gzipped budget |
|---|---|
| App shell | 120 kB |
| Feature package | 60 kB |
| Widget | 25 kB |
Numbers chosen to be slightly uncomfortable. A budget nobody ever hits is a budget nobody reads.
Lazy-loading routes is the other half. A registration form does not need to be in the bundle that renders the login page, and in a shell architecture the split points are already there — the packages are separate artefacts. Using them as split points is mostly a matter of not importing eagerly from the shell's entry.