Code artifact for the paper on runtime defects in micro-frontend (MFE) architectures. It contains four self-contained examples that demonstrate the same class of runtime defects across progressively more isolated technology stacks:
- 01 — plain HTML: zero dependencies, one file, pure browser
- 02 — Lit web components: Shadow DOM + native routing
- 03 — Angular Elements: four real Angular apps with different framework versions, composed into one shell
- 04 — Tailwind CSS v4 tokens: the four design-token configurations, measured as computed styles rather than judged by eye
The central claim they support: these defects are emergent properties of composition. Every project compiles cleanly, passes type checking, and passes its unit tests. The failure only appears when the parts are loaded together into one browser page — which is exactly why no per-project tool can catch it.
mfe-runtime-defects/
├── 01-plain-html/ Single HTML file, no build step
├── 02-lit-web-components/ Two Lit MFEs + a shell page (Vite)
├── 03-angular-elements/ The main example used in the paper
├── shell/ Angular host application (port 4200)
├── micro-frontend-1/ Angular 20.0.0 (port 4301)
├── micro-frontend-2/ Angular 21.2.6 (port 4302)
├── micro-frontend-3/ Angular 20.3.19 (port 4303)
└── micro-frontend-4/ Angular 22.2.0 (port 4304)
└── 04-tailwind-css/ Token configurations + probe harness + RESULTS.md
Each subfolder has its own README with details:
This table is central to example 03 (defect D3). The shell and each micro-frontend are independent Angular applications, each bundling its own framework runtime. They deliberately span different major versions:
| Application | Angular version | Relation to shell (21.2.6) |
|---|---|---|
| shell | 21.2.6 | — |
| micro-frontend-1 | 20.0.0 | older major, baseline minor |
| micro-frontend-2 | 21.2.6 | identical (control case, the one that works) |
| micro-frontend-3 | 20.3.19 | older major, latest patch |
| micro-frontend-4 | 22.2.0 | newer major |
The running (not declared) version is what matters. The instrumentation
described below records each application's actual VERSION.full in every
captured event, so the version matrix is verifiable from the recorded data.
Example 04 uses its own toolchain: Tailwind CSS and @tailwindcss/cli 4.2.2,
Node 24.0.0, npm 11.3.0, Google Chrome 154.0.8037.93 for the probe runs. The
exact versions behind the committed numbers are recorded in
04-tailwind-css/RESULTS.md.
- Node.js 20 or newer (any recent LTS works; the Dockerfiles use Node 20)
- npm (bundled with Node)
- Docker — optional, only for serving the MFEs as separate origins
No global packages are needed.
No build, no install:
open 01-plain-html/index.html
# or: python3 -m http.server in 01-plain-html/ and visit http://localhost:8000cd 02-lit-web-components
npm install
npm run dev # Vite dev server, port shown in the terminalThis is the main experiment. Five applications run on five ports:
| App | Port | Command (run in a separate terminal) |
|---|---|---|
| shell | 4200 | cd 03-angular-elements/shell && npm install && npm start |
| micro-frontend-1 | 4301 | cd 03-angular-elements/micro-frontend-1 && npm install && npx ng serve --port 4301 |
| micro-frontend-2 | 4302 | cd 03-angular-elements/micro-frontend-2 && npm install && npx ng serve --port 4302 |
| micro-frontend-3 | 4303 | cd 03-angular-elements/micro-frontend-3 && npm install && npx ng serve --port 4303 |
| micro-frontend-4 | 4304 | cd 03-angular-elements/micro-frontend-4 && npm install && npx ng serve --port 4304 |
Then open http://localhost:4200. The shell header lists all four
micro-frontends with their Angular versions in parentheses. Clicking one
loads that MFE's production script (http://localhost:430N/main.js) from a
different origin and renders it into the shell page.
For a faithful "deployment-like" setup, serve the MFEs from Docker instead of the dev server. Each MFE folder contains a
Dockerfileand annginx.confthat sets the CORS headers the shell needs:cd 03-angular-elements/micro-frontend-1 && docker build -t mfe1 . && docker run -p 4301:80 mfe1 # repeat for mfe2 (4302), mfe3 (4303), mfe4 (4304)If you see
Script error.with no details in the console, the MFE scripts are being blocked by the same-origin policy — the dev server / nginx CORS configuration must be in place (this is a documented part of the study: cross-origin script failures degrade error reporting to an opaqueErrorEvent).
This is the CSS experiment. It is a measurement harness, not a page to click through:
cd 04-tailwind-css
npm install
npm run verify # builds all configurations, then measures them with headless Chromenpm run verify launches headless Chrome over four token configurations and two
definition scopes and reads the computed background-color back from the DOM,
so the recorded value is the browser's own resolution rather than a human
reading. It also re-measures every configuration in isolation and prints
combined vs one-config-at-a-time: 16/16 identical, which is the check that the
side-by-side view is not perturbing the cascade. Commit the output to
RESULTS.md when you re-run it.
To see the core result without a terminal, build once and open
src/index.html: two identical boxes, each declaring its own token value, one
rendered transparent by @theme and one rendered red by @theme inline.
src/probe.html shows all four configurations at once (?scope=local or
?scope=global).
All five applications of example 03 carry identical instrumentation code
(src/runtime-debug.ts, src/runtime-error-handler.ts in each project).
When you load the shell, it installs listeners that capture, for both the
shell and each micro-frontend:
window.errorandunhandledrejection(including opaqueScript error.events)popstate(browser Back / Forward)history.pushStateandhistory.replaceState(wrapped, not replaced)- Angular router events (
NavigationStart,NavigationEnd,NavigationError) - Angular
ErrorHandlerinvocations
Every capture is logged to the console and appended to a shared, timestamped timeline exposed on the page.
After performing the test sequence below, open the browser console and run:
copy(window.__runtimeEvents)This copies the full timeline to your clipboard as JSON. Each record looks like:
{
"timestamp": "2026-09-25T10:44:29.150Z",
"application": "MFE-2",
"angularVersion": "21.2.6",
"event": "router.navigation-start",
"url": "http://localhost:4200/micro-frontend-2",
"historyState": { "index": 3, "navigationId": 7, "restoredState": null },
"historyLength": 4,
"details": { "url": "/micro-frontend-2" }
}Fields per record:
| Field | Meaning |
|---|---|
timestamp |
ISO-8601 wall-clock time of the event |
application |
Which app's instrumentation caught it: SHELL, MFE-1 … MFE-4 |
angularVersion |
Actual runtime @angular/core version of that application |
event |
window.error, unhandledrejection, popstate, history.pushState, history.replaceState, router.navigation-start / -end / -error, angular.errorHandler |
url |
window.location.href at event time |
historyState |
Deep copy of window.history.state at event time |
historyLength |
window.history.length at event time |
details |
Event-specific payload (target URL, error object, state passed to pushState, …) |
Because each application patches history by chaining onto the previous
patch, one navigation can appear in the timeline from several applications'
instrumentation. Deduplicate by timestamp when building tables; the
application fields on the duplicates show which apps were active when the
shared history changed — itself a relevant observation.
To reproduce the navigation-divergence results from the paper, perform these steps in the browser while the shell (and all four MFEs) are running:
- Load the shell → http://localhost:4200
- Navigate shell → micro-frontend-2 (the matching-version control case)
- Navigate back to shell (browser Back button)
- Navigate shell → micro-frontend-1
- Browser Back
- Browser Forward
- Navigate shell → micro-frontend-3
- Browser Back
- Navigate shell → micro-frontend-4
- Browser Back until you are on the shell
Then export window.__runtimeEvents as described above. For each step you
can produce a row of the form:
| Step | Application | URL | Router event | History event | Error |
|---|---|---|---|---|---|
| 1 | SHELL | / | navigation-end | — | — |
| 2 | SHELL | /micro-frontend-2 | navigation-end | replaceState | — |
| … | … | … | … | … | … |
The same procedure applies to examples 01 and 02 (they have no built-in timeline; use the browser console and DevTools network/timeline tools there).
The paper identifies three defect classes, all of which manifest exclusively at composition runtime:
D1 — CSS token indirection collision.
Components reference design tokens by name via CSS custom properties
(var(--token-name)). The value behind the name is resolved at runtime from
the document cascade, a shared global scope. When two independently built
micro-frontends define the same token name with different values, the
last-loaded stylesheet wins page-wide. No source file changes, no build step
reports an error, and the outcome depends on stylesheet load order —
non-deterministic across deployments. Demonstrated by examples 01 and 02.
D1b — Token indirection across scopes.
The related but distinct failure in the production system: an exported design
token forwards to another custom property (--color-primary: var(--mfe-color-primary)).
Because var() is substituted where a custom property is declared, not where
it is used, that inner reference is resolved at :root — before any
micro-frontend subtree exists. If the concrete value is only declared locally,
the chain resolves to the guaranteed-invalid value and the declaration is
discarded with no diagnostic; if the name also exists at :root, the global
value silently overrides the component's own. Measured across four
declaration strategies (including Tailwind's @theme inline) in example 04.
D2 — Route shadowing.
The browser URL is a single shared resource. Each micro-frontend that
bootstraps its own router subscribes independently to URL change events
(hashchange, popstate). Overlapping route patterns or catch-all
wildcards cause one router to intercept navigations initiated by another.
Each router is internally correct in isolation; the conflict is a property
of concurrent subscription to a shared channel.
D3 — Multi-version runtime collision (Angular-specific, example 03).
Deploying micro-frontends built on different major versions of the same
framework introduces additional hazards: global API patching (e.g. by
zone.js) affecting a co-deployed application, duplicate framework runtime
bundles increasing memory and parse cost, and divergent navigation-state
formats written to the single shared window.history.state by incompatible
router implementations. These hazards are specific to the multi-version
composition scenario.
Every micro-frontend in this repository compiles without errors, passes TypeScript type checking, and passes its unit test suite in isolation. The defects are invisible to static tooling for three reasons:
- No single compilation unit contains an error. The fault is distributed across the boundary between units.
- The triggering condition is load order, which is determined at runtime by the host page and the network, not by any source file.
- The shared resources that mediate the conflict (the CSS cascade, the
browser URL,
window.history, thewindowobject) are outside the analysis scope of any per-micro-frontend tool.