Coordinating Layer Order Across Independently Deployed Apps
When four teams deploy on four schedules into one page, the cascade layer order is a distributed contract with no compiler to check it. A remote built last Tuesday writes into apps.checkout expecting it to sit below utilities; the shell deployed this morning moved it. Nothing errors — the page just renders wrong for whoever gets the mismatched combination. Treating the order as a versioned API is what makes that tractable, extending micro-frontend cascade isolation in the architecture patterns reference.
Prerequisites
A shell that declares the layer order and a contract package every application depends on — the arrangement described in assigning layer namespaces per micro-frontend.
/* The contract, as the shell declares it today. */
@layer reset, vendor, base, theme, ds, apps, utilities, overrides;Step 1: Version the contract and publish the order as data
{
"name": "@org/cascade-contract",
"version": "3.0.0",
"//": "WHY semver on a CSS contract: a reorder changes behaviour for every consumer, which is exactly what a major version is for — even though no exported symbol changed.",
"layers": ["reset", "vendor", "base", "theme", "ds", "apps", "utilities", "overrides"],
"appOrder": ["catalog", "search", "checkout", "account"]
}What this does: gives every application a machine-readable copy of the order it was built against, which is what makes the runtime mismatch check in Step 4 possible.
Step 2: Classify the change before planning the rollout
/* ADDITIVE — safe. A new band after the last one only adds priority above
everything that already exists, so no existing relationship changes.
@layer …, utilities, overrides, experiments; ← minor version */
/* MOVE — breaking. Relationships between existing rules change, and the
symptom is a visual regression rather than a build failure.
@layer reset, vendor, … → @layer vendor, reset, … ← major */
/* REMOVE — breaking, and silently so. Any app still writing into the deleted
name creates it implicitly at the END of the stack, where it now outranks
everything. This is the most dangerous of the three. ← major */What this does: sets the rollout requirements. The removal case deserves emphasis: deleting a layer name does not make writes to it fail, it makes them win.
Step 3: Roll out shell-first, always
1. Shell deploys contract 3.1.0 — the new band exists but is empty
2. Remotes upgrade at their own pace — each writes into it when ready
3. Old band deprecated in 3.2.0 — declared but documented as legacy
4. Telemetry confirms zero writers — see Step 4
5. Shell deploys 4.0.0 — the old band is removed// WHY shell-first: a remote writing into a layer the shell has not declared
// creates it implicitly at the END of the stack, above everything. The
// reverse order — remote first — is the one that produces an incident.What this does: sequences the deploy so that at no point does an application write into a layer the shell does not know about. The window between steps 1 and 2 can be arbitrarily long, which is the whole point.
Step 4: Verify the live order from real sessions
// packages/cascade-contract/src/verify.js
// WHY sample rather than run on every load: the walk is cheap but not free,
// and a mismatch affects every session equally, so 1% finds it immediately.
import contract from '../contract.json' assert { type: 'json' };
export function reportLayerOrder({ sampleRate = 0.01, report } = {}) {
if (Math.random() > sampleRate) return;
const names = [];
const walk = (rules) => {
for (const r of rules) {
const kind = r.constructor.name;
if (kind === 'CSSLayerStatementRule') names.push(...r.nameList);
else if (kind === 'CSSLayerBlockRule') {
if (r.name) names.push(r.name);
walk(r.cssRules);
} else if (r.cssRules) walk(r.cssRules);
}
};
for (const s of document.styleSheets) {
try { walk(s.cssRules); } catch { /* cross-origin */ }
}
const actual = [...new Set(names)];
const topLevel = actual.filter((n) => !n.includes('.'));
const unexpected = topLevel.filter((n) => !contract.layers.includes(n));
const ordered = contract.layers.filter((n) => topLevel.includes(n));
// WHY compare the FILTERED expected list: an app that has not deployed yet
// legitimately leaves a declared band unused, and that is not a mismatch.
const drift = JSON.stringify(ordered) !==
JSON.stringify(topLevel.filter((n) => contract.layers.includes(n)));
if (unexpected.length || drift) {
report?.({ event: 'cascade_contract_mismatch', expected: contract.layers,
actual: topLevel, unexpected, version: contract.version });
}
}What this does: turns a class of bug that normally reaches users into a dashboard alert. The unexpected array is the useful field — an unfamiliar top-level name is almost always a remote built against a contract the shell does not have.
Verification
- Deploy the shell’s new contract to staging with no remote changes and confirm every application still renders identically — an additive change must be invisible.
- Deliberately deploy a remote built against a newer contract than the shell and confirm the mismatch telemetry fires.
- Check the live order from a production session:
// Paste into the console on production. Compare against contract.layers.
[...document.styleSheets].flatMap((s) => {
try { return [...s.cssRules]; } catch { return []; }
}).filter((r) => r.constructor.name === 'CSSLayerStatementRule')
.flatMap((r) => r.nameList).filter((n) => !n.includes('.'));- Before removing a deprecated band, confirm the telemetry has reported zero writers for a full release cycle — not a single deploy.
Troubleshooting
An app renders correctly alone and wrongly inside the shell. : It is built against a different contract version. Compare the version each bundle reports; the mismatch is almost always the app, because the shell deploys first by policy.
A layer name appears that is in no contract version.
: A dependency inside one of the remotes ships its own @layer statement. Pin it, mirror the name in the contract at the position you want, and add it to that app’s upgrade checklist.
The order is correct but one app still loses every conflict.
: Check its sibling position within apps. Sibling order is part of the contract too, and it is the part most often forgotten when a new app is registered.
Telemetry reports mismatches from a small fraction of sessions only. : A stale cached bundle. Check the cache headers on the shell’s stylesheet — a long max-age on the file that declares the contract is how a superseded order stays live for weeks.
Rolling back the shell broke apps that had already upgraded. : Expected, and worth planning for: a rollback of a major contract version is itself a breaking change in the other direction. Keep one release of forward compatibility by declaring both the old and new bands during the transition.
Complete working example
// packages/cascade-contract/contract.json — the single versioned artefact.
{
"version": "3.1.0",
"layers": ["reset", "vendor", "base", "theme", "ds", "apps",
"utilities", "overrides"],
"appOrder": ["catalog", "search", "checkout", "account"],
"deprecated": {
"legacy-ds": {
"since": "3.1.0",
"removeIn": "4.0.0",
"replacement": "ds",
"note": "still declared so writes from un-upgraded remotes land in the reviewed position rather than creating a new top-level layer"
}
}
}// packages/cascade-contract/src/index.js
import contract from '../contract.json' assert { type: 'json' };
/** WHY generated: the shell's CSS and the contract cannot drift when one is
* produced from the other at build time. */
export function layerStatements() {
const top = [...contract.layers,
...Object.keys(contract.deprecated ?? {})].join(', ');
const apps = contract.appOrder.map((id) => `apps.${id}`).join(', ');
return `@layer ${top};\n@layer ${apps};\n`;
}
/** WHY a runtime check rather than only a build check: the shell and the
* remotes are built separately, so the only place the combination exists is
* a real browser session. */
export function verifyLiveOrder({ sampleRate = 0.01, report } = {}) {
if (Math.random() > sampleRate) return;
const seen = [];
const walk = (rules) => {
for (const r of rules) {
const kind = r.constructor.name;
if (kind === 'CSSLayerStatementRule') seen.push(...r.nameList);
else if (kind === 'CSSLayerBlockRule') {
if (r.name) seen.push(r.name);
walk(r.cssRules);
} else if (r.cssRules) walk(r.cssRules);
}
};
for (const s of document.styleSheets) {
try { walk(s.cssRules); } catch { /* cross-origin sheet — skip */ }
}
const known = new Set([...contract.layers,
...Object.keys(contract.deprecated ?? {})]);
const topLevel = [...new Set(seen)].filter((n) => !n.includes('.'));
const unexpected = topLevel.filter((n) => !known.has(n));
// WHY report even when nothing is wrong is NOT done: a quiet check that
// only speaks on failure keeps the signal readable on a dashboard.
if (unexpected.length) {
report?.({
event: 'cascade_contract_mismatch',
contractVersion: contract.version,
unexpectedLayers: unexpected,
observedOrder: topLevel,
});
}
}Frequently Asked Questions
Is adding a cascade layer a breaking change?
It depends entirely on where. Appending a band after the last one is additive — it adds priority above everything that already exists without altering any relationship between existing rules, so a minor version is right. Inserting between two existing bands changes which of two already-deployed rules wins, which is a behavioural breaking change even though nothing fails to build and no name disappeared.
What happens if two deployed versions of the contract are live at once?
The shell’s declaration is the one that takes effect, because a layer’s position is fixed by its first declaration and the shell parses first. Remotes built against a different version still write by name, so any name the shell does not declare is created implicitly at the end of the stack — above the design system and above utilities. That single behaviour is why the rollout is always shell-first.
How do you detect a contract mismatch in production?
Sample the registered layer order from real sessions and compare it against the contract the reporting code was built with. An unexpected top-level name is the highest-signal field: it means some bundle wrote into a layer nobody declared, and the name usually identifies the team. Catching it in telemetry turns a class of intermittent visual bug — one that only appears for users who get a particular deployment combination — into an alert.
Related
- Assigning Layer Namespaces per Micro-Frontend — the namespace scheme this contract versions
- Micro-Frontend Cascade Isolation — the wider isolation problem
- Documenting Layer Architecture in a Monorepo — the same contract when there is a shared build
- Testing Cascade Layers Across Browsers — asserting the order before it reaches production
Up: Micro-Frontend Cascade Isolation → Architecture Patterns & Design System Scaling