Micro-Frontend Hydra Bugs: Debugging Distributed State
In a micro-frontend system, the hardest bugs rarely look hard at first. A shopper clicks Add to cart, the badge updates, the checkout drawer opens, and then one panel shows 2 items while another shows 1. Refresh the page and the problem disappears. This is the classic Hydra bug: cut off one visible symptom and two more appear somewhere else because the real defect lives in distributed state, not in a single component tree.
This tutorial shows a practical debugging loop for these failures. The goal is not to guess better. The goal is to turn one fuzzy, cross-app race into an ordered, inspectable timeline you can replay, verify, and lock down in CI.
Key Takeaway
Treat every cross-micro-frontend state change as a distributed systems event. Once you define ownership, attach a correlation ID, and journal every mutation, the Hydra bug stops being mysterious and starts looking like an ordinary sequencing defect.
Prerequisites
- A local micro-frontend setup with at least two independently mounted apps and one shared shell, event bus, or host container.
- Permission to add lightweight instrumentation around state writes and message passing.
- A browser test runner such as Playwright or Cypress for deterministic reproduction.
- A way to export logs or snapshots from the browser window for inspection.
- If payloads contain customer data, sanitize exported traces before sharing them. TechBytes' Data Masking Tool is useful for scrubbing identifiers from recorded mutation payloads.
1. Map State Ownership Before You Instrument
Most Hydra bugs exist because multiple apps believe they are allowed to write the same state. Start by naming every durable key and assigning exactly one owner. In practice, state usually falls into four buckets: source of truth, cache, derived view, and purely local UI state. Only the source of truth gets write authority.
Make that rule executable instead of leaving it in a wiki page.
export const stateOwners = {
auth: "shell",
locale: "shell",
cart: "checkout",
searchFilters: "catalog"
};
export function assertOwner(stateKey, appName) {
const owner = stateOwners[stateKey];
if (!owner) {
throw new Error(Unknown state key: ${stateKey});
}
if (owner !== appName) {
console.warn([hydra] non-owner write, {
stateKey,
owner,
attemptedBy: appName
});
}
}
Do not skip this because it feels administrative. If the catalog app, promo widget, and checkout app can all increment the cart count directly, the rest of your tracing will only prove that chaos happened faster than expected. The owner map gives you a clean first question: who was allowed to write this key?
2. Add Correlation IDs to Every Mutation
A distributed state bug is really a missing narrative. You need one ID that follows a user intent from click to final render, even if the path crosses multiple apps, async boundaries, and network calls. That ID should be attached to commands, mutations, and derived updates.
let sequence = 0;
export function createTraceMeta(sourceApp, stateKey, traceId) {
return {
traceId: traceId ?? crypto.randomUUID(),
sourceApp,
stateKey,
seq: ++sequence,
ts: performance.now()
};
}
export function publishMutation(bus, sourceApp, stateKey, payload, traceId) {
const meta = createTraceMeta(sourceApp, stateKey, traceId);
bus.publish("state:mutation", {
meta,
payload
});
return meta.traceId;
}
The important rule is consistency: one click, one traceId. If your shell emits a trace ID and a child app silently replaces it with a new one, you have already lost the timeline. Pass the same ID through event payloads, fetch headers, and any command queue the shell mediates.
Once this is in place, a cart discrepancy becomes searchable. Instead of asking why the badge says 2, you ask: show me every event with trace 9f8c... from catalog click to checkout render.
3. Build a Mutation Journal in Every App
Correlation IDs tell you which events belong together. A mutation journal tells you what each app believed before and after each write. Keep it simple: append-only, small, and exportable from the window object.
const journal = [];
export function recordMutation({ app, kind, stateKey, before, after, meta }) {
journal.push({
app,
kind,
stateKey,
before,
after,
meta
});
console.debug("[hydra]", {
app,
kind,
stateKey,
traceId: meta.traceId,
seq: meta.seq,
before,
after
});
}
window.HYDRA_EXPORT = function () {
return journal.slice();
};
Journal both outbound commands and local writes. A useful split is command, owner-write, cache-sync, and derived-render. That makes it obvious whether the defect happened because the wrong app wrote state, the right app wrote stale state, or a consumer rendered an expired cache.
Keep snapshots small. For example, record { count, itemIds } for the cart instead of serializing the full product catalog. When you do need to inspect large payloads, running the exported JSON through TechBytes' Code Formatter makes diffing much faster.
4. Enforce Single-Writer Rules with Commands
Instrumentation alone will show the bug, but the cleanest fixes usually come from architecture. In micro-frontends, readers can be many; writers should be few. Replace direct cross-app writes with commands routed to the owner.
const commandBus = new EventTarget();
export function requestCartAdd(productId, traceId) {
commandBus.dispatchEvent(
new CustomEvent("cart:add", {
detail: {
productId,
meta: createTraceMeta("catalog", "cart", traceId)
}
})
);
}
commandBus.addEventListener("cart:add", (event) => {
const { productId, meta } = event.detail;
assertOwner("cart", "checkout");
const before = checkoutStore.snapshot();
checkoutStore.add(productId);
const after = checkoutStore.snapshot();
recordMutation({
app: "checkout",
kind: "owner-write",
stateKey: "cart",
before,
after,
meta
});
});
This pattern exposes bugs quickly because non-owner apps stop mutating shared state directly. They request a change; the owner applies it. If your promo banner still writes cart.count on its own, the journal will show an unauthorized branch in the trace immediately.
In other words, debugging improves once your runtime architecture reflects your intended ownership model. The best Hydra fix is often deleting duplicate write paths, not adding more logs.
5. Verify Convergence with a Deterministic Test
Now turn the reproduction into a repeatable assertion. A useful invariant is convergence: after one user intent completes, every app that projects the same business state should agree on the final value.
import { test, expect } from "@playwright/test";
test("cart state converges across micro-frontends", async ({ page }) => {
await page.goto("/");
await page.getByTestId("product-7-add").click();
await page.getByTestId("open-mini-cart").click();
await page.getByTestId("go-to-checkout").click();
const journal = await page.evaluate(() => window.HYDRA_EXPORT());
const traceIds = [...new Set(journal.map((entry) => entry.meta.traceId))];
const ownerWrites = journal.filter((entry) => entry.kind === "owner-write");
const finalCartWrite = ownerWrites.at(-1);
expect(traceIds).toHaveLength(1);
expect(finalCartWrite.after.count).toBe(1);
const nonOwnerCartWrites = journal.filter(
(entry) => entry.stateKey === "cart" && entry.app !== "checkout" && entry.kind === "owner-write"
);
expect(nonOwnerCartWrites).toHaveLength(0);
});
This is the transition from debugging to prevention. Your test is not verifying visual polish. It is verifying distributed state discipline: one trace, one owner, one final truth.
Verification / Expected Output
When the instrumentation is working, the bug timeline becomes boring in the best possible way. You should see a single trace flow through every app involved in the scenario, with one authoritative write to the shared key and only derived updates elsewhere.
[hydra] { app: "catalog", kind: "command", stateKey: "cart", traceId: "9f8c", seq: 14 }
[hydra] { app: "checkout", kind: "owner-write", stateKey: "cart", traceId: "9f8c", seq: 15, before: { count: 0 }, after: { count: 1 } }
[hydra] { app: "shell", kind: "derived-render", stateKey: "cartBadge", traceId: "9f8c", seq: 16, before: 0, after: 1 }
- Exactly one traceId should cover the full user intent.
- Exactly one app should produce the authoritative write for each durable shared key.
- seq values should be monotonic within the trace.
- The final snapshots rendered by different apps should converge on the same business value.
Troubleshooting Top 3
1. Trace IDs change when events cross app boundaries
This usually means a legacy bus wrapper or fetch client is creating new metadata instead of preserving the incoming value. Fix the boundary adapter first, then fail tests when a shared-state mutation arrives without a traceId.
2. The UI looks correct, but the journal shows stale caches
You may have a hidden refresh masking the defect. One app briefly diverges, then later rehydrates from the owner. Add a version field to snapshots and record cache invalidation events explicitly so you can distinguish immediate correctness from eventual correction.
3. The bug only happens after one remote deploys
That points to schema drift. A newer app may publish { count, itemIds } while an older consumer still expects { items }. Put a schemaVersion on shared payloads and reject incompatible writes loudly instead of allowing silent partial reads.
What's Next
Once this workflow is in place, extend it in three directions. First, ship the mutation journal to your observability stack so browser traces and backend traces can be joined by the same correlation ID. Second, generate CI checks from your ownership map so new shared keys cannot appear without an owner. Third, add chaos-style browser tests that delay or reorder cross-app messages to flush out race conditions before production users do.
The larger lesson is architectural: micro-frontends are not just a deployment strategy. They are a distributed system running in one browser tab. When you debug them with distributed systems discipline, the Hydra bug becomes small enough to kill once.
Get Engineering Deep-Dives in Your Inbox
Weekly breakdowns of architecture, security, and developer tooling — no fluff.