2 min read

Giving a microfrontend shell a bundle budget

In an OpenMRS 3 style app shell, every feature package can quietly add to the first paint. A shared-dependency budget is what keeps that honest.

  • openmrs
  • microfrontends
  • performance

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.

Bar chart showing each additional feature package adding to the initial payload
Every package is small. The sum is not.

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-dom minor, 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 kindGzipped budget
App shell120 kB
Feature package60 kB
Widget25 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.