ADR 007: Browser CustomEvents for Inter-MFE Communication

Status: Accepted Date: 2025-03-15

Context

Micro frontends need to communicate for cross-cutting concerns: navigation, user context changes, cart updates, notifications. The communication mechanism must not create coupling between MFEs and must work with independent deployment cycles.

Decision

Use browser native CustomEvents dispatched on window for inter-MFE communication, with typed event contracts defined in a shared TypeScript package (@org/event-contracts).

Browser CustomEvents Browser CustomEvents Positive Zero runtime dependency Loose coupling Framework agnostic Simple debugging Negative No delivery guarantees No event replay Global namespace

Consequences

Positive

  • Zero runtime dependency: CustomEvent is a browser native API
  • Loose coupling: MFEs publish/subscribe without knowing about each other
  • Framework agnostic: works regardless of React version or framework
  • Type safety via shared event contract types (compile-time, not runtime)
  • Simple debugging: events visible in browser DevTools
  • No versioning issues: no shared library version to coordinate
  • Familiar API: every web developer knows addEventListener/dispatchEvent
  • Easy to test: straightforward to mock in unit tests

Negative

  • No built-in delivery guarantees (fire-and-forget, no acknowledgment)
  • No event replay: if an MFE loads after an event was dispatched, it misses it
  • Global namespace pollution (mitigated by mfe: prefix convention)
  • No back-pressure mechanism for high-frequency events
  • Runtime type safety requires manual validation (TypeScript only helps at compile time)
  • No built-in event ordering guarantees across async boundaries

Event Versioning Guidance

Since MFEs are deployed independently and may consume different versions of the @org/event-contracts package, events must be versioned carefully to avoid runtime failures.

Version Field in Payload

Every event payload must include a version field at the top level:

interface MfeEvent<T> {
  version: number; // Integer, starting at 1
  type: string;    // Event name, e.g., "mfe:cart:item-added"
  payload: T;      // Event-specific data
  timestamp: number; // Unix timestamp (ms)
}

Consumers must check the version field before processing and ignore events with unrecognized versions rather than throwing errors.

Backward-Compatible Additions (Minor Changes)

The following changes are backward-compatible and do not require a version bump:

  • Adding new optional fields to the payload. Existing consumers that do not read the new field continue to work.
  • Adding new event types to the contract package. MFEs that do not subscribe to the new type are unaffected.
  • Widening a union type (e.g., adding a new enum value to a status field), provided consumers handle unknown values gracefully with a default case.

For backward-compatible additions, the version field remains unchanged. Update the @org/event-contracts package with the new types and publish a new minor version of the package.

Breaking Change Migration Strategy

A breaking change is any modification that could cause existing consumers to fail:

  • Removing or renaming a field
  • Changing a field's type
  • Changing the semantic meaning of an existing field
  • Removing an event type

When a breaking change is necessary:

  1. Increment the version field in the event payload (e.g., version: 1 becomes version: 2).
  2. Dual-publish period: The producing MFE must emit both the old version and the new version of the event for a migration window (recommended: 2 sprint cycles or 4 weeks). This allows consuming MFEs to be updated independently.
  3. Consumer migration: Each consuming MFE adds a handler for the new version. During the dual-publish period, consumers should accept both versions. Once all consumers have migrated, the old version handler can be removed.
  4. Deprecation notice: Mark the old version's types as @deprecated in the @org/event-contracts package. Add a console warning in development mode if an MFE still emits the old version.
  5. Sunset the old version: After confirming all consumers have migrated (via telemetry or code review), remove the old version from the producing MFE and the contract types.

Versioning Best Practices

  • Keep event payloads small and focused. Smaller payloads are easier to evolve.
  • Prefer additive changes over modifications. Design payloads with extension in mind.
  • Document each event version's schema in the @org/event-contracts package README.
  • Use runtime validation (e.g., Zod or a lightweight schema check) at consumer boundaries to catch unexpected payloads early, especially during migration windows.

Alternatives Considered

Shared Redux store / Zustand store

Strong consistency and debugging (Redux DevTools). But: creates tight coupling between MFEs, version conflicts for the state library, shared store becomes a coordination bottleneck, breaks independent deployability. An MFE updating its Redux dependency could break all other MFEs.

RxJS / Observable-based event bus

Powerful composition (debounce, buffer, combine). But: adds a shared runtime dependency (RxJS version must match across all MFEs), steep learning curve for some developers, overkill for the simple pub-sub patterns we need.

postMessage (iframe-based)

Necessary for iframe micro frontends. But: we're using Module Federation (same window), postMessage serialization overhead, more complex API than CustomEvents.

Custom event emitter library

More features (wildcard listeners, once, namespaces). But: adds a shared runtime dependency that all MFEs must agree on. CustomEvents are sufficient and dependency-free.