Our team maintains a complex internal operations platform whose frontend architecture evolved to micro-frontends three years ago. Initially, communication between micro-apps relied on Custom Events and a shared global state library. This solution was manageable when the system was small, but as business complexity grew exponentially, problems began to surface. State synchronization logic across applications became brittle, and an abnormal state in one micro-app could corrupt the global state, causing cascading failures. Even worse, all transient state was lost on a page refresh, forcing users to repeat a series of actions to restore their work context. When troubleshooting, we had no way to trace the user’s action sequence, as these were just ephemeral events in browser memory.
The pain points were clear: we needed a communication mechanism that could persist across page lifecycles, be traceable, and provide industrial-grade decoupling. Instead of patching up the frontend, we decided to change our approach—by bringing the mature principles of Event-Driven Architecture (EDA) from the backend to the frontend and building a persistent micro-frontend event bus.
Initial Concept and Technology Selection
The core idea is to treat frontend UI events as first-class citizens, on par with backend business events. Every meaningful user action (e.g., triggering a build, updating a configuration, starting a task) is no longer a simple function call but is encapsulated as an event object and sent to a centralized, persistent event bus. Other micro-apps or backend services can subscribe to these events to update their own state or execute corresponding logic.
Here’s the architecture diagram for this concept:
graph TD
subgraph Browser
MFE_A[Micro-frontend A]
MFE_B[Micro-frontend B]
WS_Client[WebSocket Client]
MFE_A -- publish event --> WS_Client
MFE_B -- publish event --> WS_Client
WS_Client -- push updates --> MFE_A
WS_Client -- push updates --> MFE_B
end
subgraph Backend Infrastructure
Gateway[WebSocket Gateway]
MQ[ActiveMQ Broker]
PersistenceSvc[Event Persistence Service]
StateSvc[State Materialization Service]
DB[(PostgreSQL)]
end
WS_Client <-->|STOMP over WebSocket| Gateway
Gateway -- publish --> MQ
PersistenceSvc -- subscribe --> MQ
StateSvc -- subscribe --> MQ
MQ -- push --> Gateway
PersistenceSvc -- write event log --> DB
StateSvc -- update materialized view --> DB
Technology Selection Rationale:
Message Queue (MQ) - ActiveMQ: Why choose ActiveMQ over more popular options like RabbitMQ or Kafka? In real-world projects, technology selection isn’t just about performance benchmarks. Our operations team has over five years of production experience maintaining ActiveMQ Artemis, and our monitoring, alerting, and disaster recovery solutions are mature. Furthermore, ActiveMQ’s excellent support for the STOMP (Simple Text Oriented Messaging Protocol) protocol allows for easy integration with the browser via WebSockets. What we needed were its Durable Topics and message reliability guarantees to ensure that no “command” issued from the frontend is ever lost.
ORM - Prisma: Event Sourcing is a natural fit for this architecture. We log all events (the write model) and compute the system’s current state from this event stream (the read model). This means frequent writes to an event log and the construction of materialized views. Writing raw SQL to handle both models is tedious and error-prone. Prisma, with its strong type safety and concise API, greatly simplifies database operations. For a TypeScript project, it provides end-to-end type validation from the database schema all the way to the application code.
Frontend Build Tool - Turbopack: Our micro-frontend project is managed in a Monorepo structure. As the number of micro-apps grew into the double digits, traditional Webpack build times and local dev server startup times were severely impacting developer productivity. We evaluated Vite and Turbopack, ultimately choosing Turbopack. Built in Rust, its incremental computation engine delivered near-instant HMR (Hot Module Replacement) and blazing-fast cold starts in our large Monorepo, which was the deciding factor. This was purely a choice to improve the developer experience (DX).
Step-by-Step Implementation: From Backend to Frontend
1. Database Model Definition (Prisma Schema)
First, we need to define the models for our event log and materialized view. The event log is immutable—append-only. The materialized view is mutable and represents a snapshot of the system’s current state.
./packages/db/prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
output = "../src/generated/client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Logs all events from the frontend. This is the Source of Truth.
model MfeEventLog {
id String @id @default(cuid())
// Unique event identifier for idempotency.
eventId String @unique
// Event type, e.g., 'service.restart', 'config.update'.
type String
// Event payload in JSON format.
payload Json
// Identifier of the source micro-frontend.
source String
// User identifier.
userId String
// Event creation timestamp.
createdAt DateTime @default(now())
@@index([type])
@@index([userId, createdAt])
}
// Materialized view: current status of a service (the read model).
// It is updated by events and used for fast queries.
model ServiceStatus {
id String @id
// Service name.
name String
// Current status: 'RUNNING', 'STOPPED', 'RESTARTING'.
status String
// Configuration version number.
configVersion Int
// ID of the last event that modified this record.
lastEventId String
// Last update timestamp.
updatedAt DateTime @updatedAt
@@index([status])
}
2. Core Backend Services: WebSocket Gateway and Event Handlers
We use Node.js, Express, and the stomp-broker-js library to build the WebSocket gateway. This gateway acts as the bridge between the frontend and ActiveMQ.
./packages/gateway/src/server.ts
import http from 'http';
import express from 'express';
import { StompServer } from 'stomp-broker-js';
import WebSocket from 'ws';
import { logger } from './utils/logger';
const app = express();
const server = http.createServer(app);
// ActiveMQ STOMP connection config.
// In production, these should come from environment variables.
const stompConfig = {
host: 'activemq-broker.internal',
port: 61613,
login: 'admin',
passcode: 'secret_password',
// Enable reconnection mechanism.
reconnect: {
retries: -1, // Infinite retries
delay: 5000, // 5-second interval
},
};
const stompServer = new StompServer({
server: new WebSocket.Server({ server }),
brokerURL: `stomp://${stompConfig.host}:${stompConfig.port}`,
wsHandler: (stompSocket, wsSocket, req) => {
logger.info({ ip: req.socket.remoteAddress }, 'New WebSocket client connected.');
// Authentication logic can be added here, e.g., validating a cookie or token in `req`.
},
debug: (msg) => {
// Pipe stomp-broker-js debug logs into our logging system.
logger.trace(msg);
}
});
stompServer.on('error', (err) => {
logger.error({ error: err }, 'STOMP server error.');
});
stompServer.on('connected', () => {
logger.info('Gateway successfully connected to ActiveMQ broker.');
});
const PORT = process.env.PORT || 3001;
server.listen(PORT, () => {
logger.info(`WebSocket Gateway listening on port ${PORT}`);
});
process.on('SIGTERM', () => {
logger.info('SIGTERM signal received. Closing connections.');
server.close(() => {
stompServer.close(() => {
logger.info('All connections closed. Exiting.');
process.exit(0);
});
});
});
This gateway handles no business logic; it transparently forwards STOMP frames.
Next are two independent microservices that subscribe to ActiveMQ topics to process the business logic.
./packages/persistence-service/src/index.ts
import { Client } from '@stomp/stompjs';
import { PrismaClient } from '@my-org/db';
import { logger } from './utils/logger';
import { z } from 'zod';
// Use Zod to define the event schema for message format validation.
const eventSchema = z.object({
eventId: z.string().uuid(),
type: z.string().min(1),
payload: z.record(z.unknown()),
source: z.string().min(1),
userId: z.string().min(1),
});
const prisma = new PrismaClient();
const client = new Client({
brokerURL: `ws://activemq-broker.internal:61613`, // Note: use ws or stomp depending on broker config
connectHeaders: {
login: 'admin',
passcode: 'secret_password',
},
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConnect = (frame) => {
logger.info('Persistence service connected to ActiveMQ.');
// Subscribe to a wildcard topic to receive all micro-frontend events.
client.subscribe('/topic/mfe.events.>', (message) => {
try {
const rawEvent = JSON.parse(message.body);
const parsedEvent = eventSchema.parse(rawEvent);
// Core logic: persist the event to the database.
prisma.mfeEventLog.create({
data: {
eventId: parsedEvent.eventId,
type: parsedEvent.type,
payload: parsedEvent.payload,
source: parsedEvent.source,
userId: parsedEvent.userId,
}
}).then(() => {
logger.info({ eventId: parsedEvent.eventId, type: parsedEvent.type }, 'Event persisted successfully.');
}).catch(err => {
// Handle unique key constraint violation for idempotency.
if (err.code === 'P2002' && err.meta?.target?.includes('eventId')) {
logger.warn({ eventId: parsedEvent.eventId }, 'Duplicate event received. Ignoring.');
} else {
logger.error({ error: err, eventId: parsedEvent.eventId }, 'Failed to persist event.');
// In a real project, this should push the failed message to a dead-letter queue (DLQ).
}
});
} catch (error) {
logger.error({ error, body: message.body }, 'Failed to parse or process message.');
}
});
};
client.onStompError = (frame) => {
logger.error(`Broker reported error: ${frame.headers['message']}`);
logger.error(`Additional details: ${frame.body}`);
};
async function main() {
await prisma.$connect();
client.activate();
}
main().catch(e => {
logger.error(e);
process.exit(1);
});
This service is the “write model” processor. Its sole responsibility is to record events.
./packages/state-service/src/processor.ts
import { PrismaClient, ServiceStatus } from '@my-org/db';
import { logger } from './utils/logger';
const prisma = new PrismaClient();
// A map of event handling logic.
// The key is that each handler is a pure function: (state, event) => newState
const eventHandlers: { [key: string]: Function } = {
'service.restarted': async (event: any): Promise<void> => {
const { serviceId } = event.payload;
await prisma.serviceStatus.update({
where: { id: serviceId },
data: {
status: 'RUNNING',
lastEventId: event.eventId,
},
});
logger.info({ serviceId }, 'Service status updated to RUNNING.');
},
'service.restart.initiated': async (event: any): Promise<void> => {
const { serviceId, initiatedBy } = event.payload;
await prisma.serviceStatus.upsert({
where: { id: serviceId },
update: {
status: 'RESTARTING',
lastEventId: event.eventId,
},
create: {
id: serviceId,
name: `Service-${serviceId}`, // In a real app, name should come from event or DB
status: 'RESTARTING',
configVersion: 1,
lastEventId: event.eventId,
}
});
logger.info({ serviceId, initiatedBy }, 'Service status updated to RESTARTING.');
},
// ... other event handlers
};
export async function processEvent(event: any): Promise<void> {
const handler = eventHandlers[event.type];
if (handler) {
try {
await handler(event);
} catch (error) {
logger.error({ error, eventId: event.eventId }, `Error processing event for state projection.`);
// Failure handling, e.g., log error or send alert.
}
} else {
logger.trace({ type: event.type }, 'No state handler for this event type.');
}
}
// The main entry point for StateService would subscribe to topics
// just like PersistenceService, and then call the processEvent function.
This service is the “read model” builder. It consumes the event stream and updates materialized views for fast frontend queries.
3. Frontend Integration: Turbopack Config and React Hook
In the Monorepo root, turbo.json is configured as follows. Turbopack will execute build and dev tasks in parallel based on task dependencies.
./turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"lint": {},
"dev": {
"cache": false,
"persistent": true
}
}
}
Next is the core of the frontend, a reusable React Hook to encapsulate interaction with the event bus.
./packages/ui-hooks/src/useEventBus.ts
import { useState, useEffect, useRef, useCallback } from 'react';
import { Client, IMessage, StompSubscription } from '@stomp/stompjs';
import { v4 as uuidv4 } from 'uuid';
// Note: In a real project, this URL should be configurable.
const GATEWAY_URL = 'ws://localhost:3001';
interface EventBusState {
isConnected: boolean;
error: string | null;
}
interface PublishOptions {
source: string; // Micro-app identifier
userId: string;
}
export function useEventBus() {
const [state, setState] = useState<EventBusState>({ isConnected: false, error: null });
const clientRef = useRef<Client | null>(null);
const subscriptionsRef = useRef<Map<string, StompSubscription>>(new Map());
useEffect(() => {
// Initialize the STOMP client
const client = new Client({
brokerURL: GATEWAY_URL,
reconnectDelay: 5000,
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
onConnect: () => {
setState({ isConnected: true, error: null });
console.log('EventBus connected.');
},
onDisconnect: () => {
setState({ isConnected: false, error: null });
console.log('EventBus disconnected.');
},
onStompError: (frame) => {
const errorMessage = `Broker error: ${frame.headers['message']} | Details: ${frame.body}`;
setState(s => ({ ...s, error: errorMessage }));
console.error(errorMessage);
},
});
client.activate();
clientRef.current = client;
return () => {
client.deactivate();
console.log('EventBus deactivated.');
};
}, []);
const publish = useCallback((type: string, payload: object, options: PublishOptions) => {
if (!clientRef.current || !state.isConnected) {
console.error('Cannot publish: EventBus is not connected.');
// In a production environment, you might consider queuing events to be sent upon reconnection.
return;
}
const event = {
eventId: uuidv4(),
type,
payload,
source: options.source,
userId: options.userId,
};
// Topic format: mfe.events.<event_type>
const destination = `/topic/mfe.events.${type}`;
clientRef.current.publish({
destination,
body: JSON.stringify(event),
headers: { 'content-type': 'application/json', 'persistent': 'true' }
});
}, [state.isConnected]);
const subscribe = useCallback((topic: string, callback: (message: IMessage) => void) => {
if (!clientRef.current) {
console.error('Cannot subscribe: client is not initialized.');
return () => {};
}
// If already connected, subscribe immediately.
if (clientRef.current.connected) {
const sub = clientRef.current.subscribe(topic, callback);
subscriptionsRef.current.set(topic, sub);
} else {
// If not connected, subscribe after connection is established.
// This would require extending the client's onConnect logic.
// For simplicity, we assume subscribe is called when connected.
console.warn('Subscribing while disconnected. Subscription will activate on connect.');
// A more robust solution would queue subscriptions.
}
// Return an unsubscribe function.
return () => {
const sub = subscriptionsRef.current.get(topic);
if (sub) {
sub.unsubscribe();
subscriptionsRef.current.delete(topic);
}
};
}, []);
return { ...state, publish, subscribe };
}
4. Micro-App in Action
Let’s assume we have two micro-apps: ControlPanel for performing actions and StatusDashboard for displaying status.
./apps/control-panel/src/components/ServiceController.tsx
import React from 'react';
import { useEventBus } from '@my-org/ui-hooks';
const CURRENT_USER_ID = 'user-123'; // Should come from an auth context in a real app
const MFE_SOURCE = 'ControlPanel';
export function ServiceController({ serviceId }: { serviceId: string }) {
const { publish, isConnected } = useEventBus();
const handleRestart = () => {
if (!isConnected) {
alert('Connection to system bus lost. Please wait.');
return;
}
console.log(`Publishing restart event for ${serviceId}`);
publish(
'service.restart.initiated',
{ serviceId, initiatedBy: CURRENT_USER_ID },
{ source: MFE_SOURCE, userId: CURRENT_USER_ID }
);
};
return (
<div>
<h3>Service: {serviceId}</h3>
<button onClick={handleRestart} disabled={!isConnected}>
Restart Service
</button>
</div>
);
}
./apps/status-dashboard/src/components/ServiceStatusDisplay.tsx
import React, { useState, useEffect } from 'react';
import { useEventBus } from '@my-org/ui-hooks';
import { IMessage } from '@stomp/stompjs';
// Initial state could be loaded from an API.
const initialStatus = 'LOADING';
export function ServiceStatusDisplay({ serviceId }: { serviceId: string }) {
const [status, setStatus] = useState(initialStatus);
const { subscribe } = useEventBus();
useEffect(() => {
// Subscribe to status updates for this specific service.
const topic = `/topic/state.updates.service.${serviceId}`;
// Assume the backend StateService publishes updates to this more specific
// topic after updating the materialized view.
const unsubscribe = subscribe(topic, (message: IMessage) => {
try {
const update = JSON.parse(message.body);
setStatus(update.status);
} catch (e) {
console.error('Failed to parse status update', e);
}
});
// Unsubscribe when the component unmounts.
return () => unsubscribe();
}, [serviceId, subscribe]);
return (
<div>
<span>Service {serviceId} Status:</span>
<strong>{status}</strong>
</div>
);
}
With this, we have closed the loop. A user clicks a button in ControlPanel, an event is sent via WebSocket to ActiveMQ, processed by PersistenceService and StateService. After updating the database, StateService might publish a state change event to another topic, which is finally received by StatusDashboard to update the UI. The entire process is asynchronous, decoupled, and persistent.
Limitations and Future Outlook
This architecture is no silver bullet. It introduces significantly more complexity and latency than traditional frontend state management solutions. For UI interactions requiring millisecond-level responsiveness (like drag-and-drop or real-time input validation), this system is entirely unsuitable. Its value lies in handling low-frequency, high-value user operations that represent important business state transitions.
The current WebSocket gateway is a potential single point of failure and performance bottleneck. In a production environment, it would need to be designed as a horizontally scalable cluster with a load balancer distributing connections. Furthermore, event schema management is critical. As the system evolves, introducing a mechanism like a Schema Registry will be necessary to ensure backward compatibility and prevent service disruptions due to changes in event format.
A future optimization could be to explore more granular message routing. For instance, the frontend could subscribe directly to topics for specific resources it cares about (e.g., /topic/state.updates/service/abc-123), rather than subscribing to a broad topic and filtering on the client side. This would require the gateway to have more intelligent authorization and topic-mapping capabilities but could significantly reduce unnecessary network traffic and frontend processing load.