Event Driven Microfrontend at Scale: Connectic
Heyyyyy,
Picture this: you're working on a large frontend platform. Multiple teams. Multiple apps. Each team owns their microfrontend slice of the product, deploys it independently, and has the autonomy to make technical decisions within their boundary. It's the microfrontend dream.
Then a user logs in through the shell application. The auth token is refreshed. Three independent micro-apps each running in their own React context, each with their own memory — still hold the old token. One makes an API call. Gets a 401. Shows the user an error they don't understand.
The auth state changed. Nobody was notified. The failure was silent, intermittent, and incredibly annoying to reproduce.
This is not a hypothetical. It's the specific problem that led me to build Connectic.
Let's get cracking,
The Problem with Independent Frontends
Microfrontends solve a real organisational problem: they let large teams work independently on different parts of a product without constant coordination overhead. Each team owns their code, their deployments, their technical choices. The product composes them at runtime.
The catch is that these independent apps still need to share state. They share the user's authentication context. They share user preferences (theme, language, notifications). They share data that one app produces and another needs to display. In a monolith, this is straightforward — everything is in the same memory. In a microfrontend architecture, the memory boundaries are real.
The three naive solutions all have serious problems at scale:
window.postMessage
postMessage works for one-way broadcasts between frames. But there's no contract — you're passing strings or serialized objects with no type safety. If team A changes the shape of a user:updated message, team B's handler silently receives the wrong shape and does something unexpected. The only way to catch it is to have a human who knows about both sides.
At small scale (two apps, one team), this is manageable. At medium scale (eight apps, three teams, rotating engineers), it becomes a source of production bugs that are difficult to attribute.
localStorage with polling
Reading a shared value from localStorage and polling it on an interval. It works. The problems: polling intervals are either too long (stale state for N seconds) or too short (unnecessary CPU and battery drain, especially on mobile). Updates don't propagate instantly — they propagate at the next poll tick. And polling across many components multiplies the overhead.
Custom browser events
document.dispatchEvent(new CustomEvent('auth:updated', { detail: user })) — better than postMessage, but still one-way. There's no request/response primitive. There's no way to ask a question and wait for an answer. If the auth app that holds the canonical user state is slow to initialize and a consumer fires before it's ready, the event is lost with no recovery mechanism.
What Connectic Provides
Connectic is a framework-agnostic communication library for microfrontend architectures. It runs on window.__connectic as a shared coordination singleton — any script loaded on the page can access it, regardless of framework, build tool, or deployment strategy.
Three primitives:
Pub/sub — event-based communication for fire-and-forget notifications
Reactive state — shared state with real-time synchronisation across boundaries Request/response — ask/answer with caching and late-binding support
What makes Connectic useful is that they're implemented together, with a type-safe API, in a way that handles the lifecycle edge cases (late initialization, message replay, provider registration timing) that the naive alternatives don't.
Pub/Sub
// In the auth app — emit after token refresh
connectic.publish('auth:token-refreshed', { token: newToken, expiresAt });
// In any other app — react to the refresh
connectic.subscribe('auth:token-refreshed', ({ token, expiresAt }) => {
apiClient.setAuthToken(token);
});
The contract is the event name and the payload shape. Both sides agree on what auth:token-refreshed carries. A TypeScript definition shared via a @your-product/events package makes this contractual — the compiler catches shape mismatches before they reach production.
Reactive State
Pub/sub works for events. But some shared values need to be readable at any time, not just when an event fires. A new micro-app initializing doesn't know what events have already fired. It needs to read the current state.
// Auth app sets user state after login
connectic.setState<User>('user', { id, name, role, hospitalId });
// Any app reads current user state on mount
const user = connectic.getState<User>('user');
// Any app subscribes to future changes
connectic.watchState<User>('user', (updatedUser) => {
updateLocalContext(updatedUser);
});
getState returns the current value synchronously — no wait, no subscription needed if you just want to read the value once. watchState subscribes to subsequent changes. An app that initializes after the state was set can call getState and get the current value immediately.
This eliminates the timing race that plagues event-only architectures: "did I miss the login event because my app loaded after auth already completed?"
Request/Response with Caching
Sometimes a micro-app needs data that another app owns, on-demand. "What are the current user's permissions?" — the permissions are owned by the auth app, but any app might need to check them.
// Auth app registers as the provider
connectic.provide('user:permissions', async () => {
return fetchCurrentUserPermissions();
});
// Any app requests on-demand
const permissions = await connectic.request<Permission[]>('user:permissions');
The response is cached with a configurable TTL. The second app to call connectic.request('user:permissions') within the cache window gets the cached result without triggering another fetch. This is the pattern that eliminates the "every micro-app calls /me on mount" problem — the user data is fetched once by the app that owns it, and subsequent requests are served from cache.
The Lifecycle Edge Cases
The primitives described above are straightforward. The engineering work is in the edge cases, the situations that only appear in production with real timing.
Late Initialization
A micro-app that subscribes to auth:token-refreshed might initialize after the auth app has already fired that event. In a naive pub/sub system, the subscription is registered too late and the event is missed permanently.
Connectic uses a brief replay buffer for pub/sub events: events fired within the last N milliseconds are replayed to new subscribers. The window is short enough to avoid replaying stale data, and long enough to cover the typical initialization timing gap between apps loaded from a CDN on the same page.
For reactive state, this isn't needed, getState always returns the current value regardless of when the subscriber joined.
Provider Not Yet Ready
For request/response, the provider might not be registered when the first consumer calls connectic.request. The consumer shouldn't fail, it should wait.
connectic.request returns a Promise that resolves when a provider registers and responds. If the provider is already registered, it resolves immediately. If not, the Promise queues the request and resolves when the provider comes online. This makes initialization order irrelevant: consumers can safely call request before providers are ready.
This has a failure case: if the provider never registers (because the auth app crashed, or wasn't loaded), the Promise hangs. Connectic uses a configurable timeout — after N seconds without a provider response, the Promise rejects with a ProviderTimeoutError. The consumer handles this as an outage, not a hang.
Cleanup
Subscriptions and providers must be cleaned up when a micro-app unmounts. A subscription that's never removed will receive events after the subscribing component is gone — in React, this produces the classic "setState on unmounted component" warning. In worse cases, it leaks memory.
Connectic returns cleanup functions from subscribe and provide. In React, this pairs directly with useEffect return values:
useEffect(() => {
const unsub = connectic.subscribe('auth:token-refreshed', handleTokenRefresh);
return unsub; // cleanup on unmount
}, []);
The Type Safety Strategy
Cross-app communication without type safety is a liability. The contract between apps is implicit — a developer changing the shape of an event payload doesn't know who's consuming it or whether their change breaks anything.
Connectic's type system works through generics: connectic.publish<T>(event, payload), connectic.request<T>(key), connectic.getState<T>(key). The types are specified at the call site.
The stronger pattern is a shared event catalogue. A @your-product/connectic-types package defines the event names and their payload types:
// @your-product/connectic-types
export interface ConnecticEvents {
'auth:token-refreshed': { token: string; expiresAt: number };
'auth:logged-out': { reason: 'timeout' | 'manual' };
'user:preferences-updated': { theme: 'light' | 'dark'; language: string };
}
export interface ConnecticState {
user: User | null;
theme: 'light' | 'dark';
}
Every app that publishes or subscribes imports from this package. Shape changes require a package version bump. Consumers that haven't updated to the new types get TypeScript errors pointing exactly to the changed call sites.
This doesn't fully solve the deployment coordination problem (two apps running different versions of the type package won't catch mismatches at runtime), but it catches mismatches at build time within a CI pipeline that builds all apps together.
Trade-offs
window.__connectic is a global. This is a deliberate choice, it's what makes framework-agnostic communication possible without a shared bundler. The cost: any script on the page can read or write to Connectic's state. This is the same trust model as localStorage or window.postMessage, and it requires the same kind of defense: validate your inputs, don't put secrets in shared state, treat cross-app data as coming from an untrusted source just as you'd treat any cross-origin message.
In-memory state is not durable. A page reload clears Connectic's reactive state. If a micro-app needs to preserve state across page loads, it should persist to localStorage or sessionStorage and hydrate Connectic state from there on initialization. Connectic is a communication bus, not a storage layer.
The pub/sub replay buffer can produce stale re-deliveries. If an event is fired, and a slow-loading app subscribes within the replay window, it will receive the event. This is usually desirable. In cases where the event is "user clicked confirm on this destructive action," replaying to a late subscriber could cause an unintended second effect. The solution is to design events that are idempotent — receiving the same event twice should produce the same result as receiving it once. This is a general distributed systems principle, not a Connectic-specific issue, but it's worth internalizing when designing your event catalogue.
Per-process circuit breakers don't survive horizontal scale. Wait, wrong article. But the in-memory nature of Connectic state is the analogous concern: on a server-rendered microfrontend architecture (where apps are rendered server-side and composed), Connectic's client-side state model doesn't apply cleanly. It's designed for client-side rendered or hydrated microfrontends, not server-rendered shell/fragment compositions.
The request/response pattern is point-to-point, not broadcast. connectic.request('user:permissions') calls the registered provider. If two apps register as providers for the same key (a misconfiguration), Connectic uses the last-registered provider. There's no multi-provider fanout or consensus model — which is intentional simplicity, but it means you need clear conventions about which app owns which request key.
When You Don't Need This
A word on over-engineering, because it's the more common failure mode:
If your "microfrontends" are feature folders in a single Vite project that share the same build output, you don't have cross-boundary communication needs. You have a folder structure problem. The previous article in this series (FSD) handles that case.
Connectic makes sense when:
Multiple apps are deployed independently with separate CI pipelines
Different teams own different apps and may be running different framework versions
The apps genuinely run as separate JavaScript bundles on the same page
You've been bitten by the auth token / stale state problem described in the opening
If you're not in that situation, a shared React context or a Redux store is the right tool. Connectic is the right tool for genuine cross-bundle, cross-team state coordination — not as a replacement for state management inside a single app.
Evaluation
Connectic has been used in production microfrontend setups across several applications. The properties it provides:
Team independence — Teams can publish events and register state keys without coordinating with every other team. The contract is the event catalogue. Nothing else requires coordination.
Late-binding correctness — Apps that initialize in any order still receive current state and queued requests. Initialization timing is not a source of bugs.
Observable communication — Because all communication flows through window.__connectic, it's possible to instrument it centrally. A connectic.debug() mode logs all published events, state changes, and request/response cycles to the console. Debugging cross-app state issues becomes a matter of opening DevTools, not adding console.log to five different repos.
Framework agnosticism — A Vue app and a React app on the same page communicate through the same Connectic instance without either knowing what framework the other is using. This is the property that module federation and import maps can struggle to provide cleanly.
The Honest Conclusion
Microfrontend state coordination is one of those problems that's easy to underestimate until you've been burned by it. The naive solutions (postMessage, localStorage polling, custom events) each work for simple cases and each fail in specific ways as the number of apps and teams grows.
Connectic exists because I needed the pub/sub + reactive state + request/response combination to work reliably with proper lifecycle handling, in a framework-agnostic way, without requiring all teams to use the same build toolchain.
The 3,000+ npm downloads suggest other people needed the same thing. If you're in the same situation, it's on npm and open source. If you're not, don't add it to a project that doesn't need it.
Use the simplest tool that solves the actual problem. Add complexity when the simpler tool fails.


