Islands Architecture vs Micro-Frontends: Boundary Management & Data Synchronization
Frontend modularity comes in two distinct forms, and choosing between them shapes both your performance budget and your teamβs deployment autonomy for years. Micro-frontends prioritize independent delivery cycles β each squad ships a self-contained application that is composed at runtime. Islands architecture, a specialization of partial hydration within the broader Core Islands Architecture & Hydration Models approach, optimizes instead for rendering efficiency β deferring JavaScript until the browser actually needs it. This page maps the precise technical divergence between the two, including boundary enforcement mechanisms, data synchronization primitives, step-by-step integration patterns, and a decision framework for teams navigating the trade-offs.
Concept Definition & Scope
Both paradigms split a monolithic frontend into independently managed pieces, but they operate on different axes:
- Micro-frontends (MFEs): Runtime composition of independently deployed applications. The orchestrator β typically Webpack Module Federation, Single-SPA, or Piral β fetches remote manifests at load time, resolves shared dependency scopes, and mounts application shells into designated DOM roots. Isolation is enforced at the application level.
- Islands architecture: Compile-time decomposition of a single applicationβs JavaScript into discrete, lazily activated chunks. A server-rendered HTML shell ships first; framework runtimes attach only to annotated interactive nodes, driven by directives like
client:load,client:visible, orclient:idle. Isolation is enforced at the component level.
The two are not substitutes for each other β they solve orthogonal problems. MFEs answer βhow do ten teams ship independently without breaking each other?β; islands answer βhow does a page deliver a fast First Contentful Paint and a low Interaction to Next Paint even when packed with interactive widgets?β.
What falls out of scope here: isomorphic SSR without explicit hydration boundaries (plain Next.js pages router), server-only React components without client directives (React Server Components β a related but distinct model), and monorepo tooling for MFE source sharing.
Architectural Divergence: Runtime vs Compile-Time
The most consequential difference is when composition happens.
In micro-frontend architectures, the browser cannot render meaningful content until the orchestrator has fetched remote manifests, resolved shared dependency scopes, and downloaded the relevant application chunks. Every additional remote increases the waterfall depth. In an islands-based system the browser renders fully-formed HTML immediately; JavaScript is an optional enhancement that loads only when a specific user event or viewport signal demands it.
Technical Mechanics
Hydration Boundary Enforcement in Islands
Islands frameworks encode isolation at the compiler. Astroβs component compiler reads directives at build time and emits HTML that wraps each interactive component in a custom element marker. The client-side scheduler reads those markers and schedules hydration tasks independently.
---
// src/pages/dashboard.astro
// These imports are resolved at build time; only the directives decide
// when β and whether β each component's JavaScript ships to the browser.
import DataGrid from '../components/DataGrid.jsx';
import InteractiveChart from '../components/Chart.jsx';
import AnalyticsWidget from '../components/Analytics.jsx';
---
<h1>Dashboard</h1>
Each island owns its own JavaScript bundle. The DataGrid script never blocks InteractiveChart hydration; failures are scoped to a single subtree. Compare this with the progressive enhancement model, where the baseline HTML remains functional independent of the hydration state.
Module Federation: Runtime Composition in Micro-Frontends
Webpack Module Federation exposes components or pages as asynchronous remote modules. The host application imports them at runtime, which introduces a mandatory network round-trip before any remote-owned content can render.
// host-app/bootstrap.js β Webpack Module Federation consumer
// This dynamic import triggers a fetch of remoteEntry.js before
// any remote component can be rendered, adding unavoidable latency.
import('./remoteApp/Widget')
.then(({ default: Widget }) => {
const container = document.getElementById('widget-root');
// createRoot call happens AFTER chunk resolution; visible to users as blank space
const root = ReactDOM.createRoot(container);
root.render(<Widget sharedState={window.__SHARED_STATE__} />);
})
.catch(err => {
// A failed remote leaves the DOM node blank; robust MFE shells need a fallback UI
console.error('Remote chunk failed to load:', err);
renderFallback(container);
});
The shared configuration in ModuleFederationPlugin attempts to de-duplicate framework runtimes across remotes, but version mismatches force duplicate downloads. A page with three remotes each on slightly different React minor versions can ship three copies of react-dom.
Comparison Table
| Dimension | Islands Architecture | Micro-Frontends |
|---|---|---|
| Composition timing | Compile-time (directives in source) | Runtime (manifest fetch + chunk resolution) |
| Initial JS payload | 40β90 % lower (zero for static islands) | High β shell + shared deps + remote manifests |
| Team deployment coupling | Coordinated static builds per release | Independent β each remote deploys on its own cycle |
| State isolation | Component-scoped via hydration boundary | Application-scoped via Shadow DOM / iframe / CSS Modules |
| Cross-boundary communication | CustomEvent, URL params, SSE |
window.postMessage, shared event bus, global store |
| Routing model | File-based static or minimal client router | Orchestrator-driven client router per remote |
| SSR/SSG support | First-class β HTML ships complete | Complex β requires server-side rendering of remote chunks |
| Core Web Vitals impact | Strong LCP and INP gains from deferred JS | Moderate β duplicate runtimes and hydration waterfalls increase TBT |
| When to choose | Performance-critical pages; centralized team | Large org with autonomous squads; independent release cycles |
Step-by-Step Integration Pattern
Option A β Pure Islands Project (Astro)
Step 1 β Install and initialise Astro:
npm create astro@latest my-islands-app
# Choose: "Empty project" β TypeScript β install dependencies
Step 2 β Add a framework renderer (React example):
npx astro add react
# Astro writes the integration to astro.config.mjs automatically
Step 3 β Author an island component with an explicit directive:
---
// src/pages/index.astro
// No client directive on StaticCard β zero JS shipped for it
import StaticCard from '../components/StaticCard.astro';
import SearchBox from '../components/SearchBox.tsx';
---
Step 4 β Verify the build output:
npm run build
# dist/ should contain one HTML file and a small JS chunk only for SearchBox.
# Open dist/index.html and confirm no <script> for StaticCard exists.
Option B β Module Federation (Webpack 5)
Step 1 β Configure the host:
// webpack.config.js (host application)
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
// Points to the remote's manifest; version pinning prevents runtime surprises
remoteApp: 'remoteApp@https://cdn.example.com/remote/remoteEntry.js',
},
shared: {
// Singleton prevents duplicate React downloads when minor versions align
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};
Step 2 β Expose a component from the remote:
// remote-app/webpack.config.js
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
// The key becomes the import path used by the host: import('./remoteApp/Widget')
'./Widget': './src/components/Widget',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});
Step 3 β Handle loading state and failure:
// host-app/src/App.jsx
import { lazy, Suspense } from 'react';
// lazy() defers the remote fetch until the component is first rendered
const RemoteWidget = lazy(() => import('remoteApp/Widget'));
export default function App() {
return (
<Suspense fallback={<div aria-live="polite">Loading widgetβ¦</div>}>
<RemoteWidget />
</Suspense>
);
}
Data Synchronization Workflows
Cross-boundary state coordination is where the two approaches diverge most sharply in day-to-day development.
Islands: Native Events and URL State
Islands avoid global stores by design. Cross-boundary prop passing documents how server-provided initial data flows into islands via serialized attributes. For client-to-client communication between islands, native CustomEvent dispatch over window is the lowest-friction primitive.
// island-a.tsx β chart island publishes its selected range
// CustomEvent is serializable and does not require a shared module
function onRangeSelect(range: { start: number; end: number }) {
window.dispatchEvent(
new CustomEvent('island:range-select', {
detail: { range, source: 'chart' },
// bubbles: false keeps the event scoped to listeners that opt in via window
})
);
}
// island-b.tsx β table island subscribes without importing island-a
import { useEffect, useState } from 'react';
export function DataTable() {
const [range, setRange] = useState<{ start: number; end: number } | null>(null);
useEffect(() => {
function handleSync(e: CustomEvent) {
if (e.detail.source === 'chart') setRange(e.detail.range);
}
window.addEventListener('island:range-select', handleSync as EventListener);
return () => window.removeEventListener('island:range-select', handleSync as EventListener);
}, []);
// Island re-renders only its own subtree β no global store, no cascade
return <table>{/* render rows filtered by range */}</table>;
}
For persistent state that survives navigation, encode it in URL search params. Both islands can read new URL(location.href).searchParams independently at hydration time, removing the need for any shared module.
Server-driven state stream workflow:
- Islands render from server-fetched initial data embedded as JSON in
data-*attributes. - On hydration, each island reads its own
data-initial-stateattribute and initializes local state. - A single SSE connection (or WebSocket) sends delta payloads; islands apply updates via their own reactive primitives.
- No island depends on another islandβs in-memory state.
Micro-Frontends: Shared Store or Event Bus
MFEs typically need a shared state layer because each remote application may need to react to authentication events, cart updates, or routing changes that originate in a different remote. The two dominant patterns are a global event bus on window and a singleton store injected through the shared scope.
// Shared event bus (framework-agnostic, lives in a separately deployed shared module)
// Exposed via Module Federation's `shared` config so all remotes reference the same instance
class EventBus {
#handlers = new Map();
on(event, handler) {
if (!this.#handlers.has(event)) this.#handlers.set(event, []);
this.#handlers.get(event).push(handler);
}
emit(event, payload) {
(this.#handlers.get(event) ?? []).forEach(h => h(payload));
}
}
// Singleton pattern ensures all MFE remotes share the same instance at runtime
export const bus = new EventBus();
The shared event bus couples remotes to the bus contract. A breaking change to event payload shape requires coordinated releases across all subscribers β the exact coupling MFE architectures try to avoid.
Measurement & Validation
Measuring Islands Hydration Cost
Instrument each islandβs hydration with the User Timing API to surface cost in Lighthouse traces:
// Drop this into your island's initialization code (Astro, SvelteKit, Qwik)
// performance.measure creates spans visible in the "Timings" row of a DevTools Performance trace
performance.mark('island:chart:hydrate-start');
// ... framework hydration happens here ...
performance.mark('island:chart:hydrate-end');
performance.measure(
'island:chart:hydration',
'island:chart:hydrate-start',
'island:chart:hydrate-end'
);
Open Chrome DevTools β Performance β Timings row. You should see a narrow, isolated bar for each island rather than a single large βHydrateβ block spanning the full application tree.
Network Waterfall Profiling (Chrome DevTools)
- Open Network tab β disable cache β throttle to Slow 4G.
- Load the page and record the waterfall.
- Islands baseline: one HTML document arrives fully formed; JavaScript files appear only after their trigger fires (scroll, idle, interaction). Script evaluation tasks are short and isolated.
- MFE baseline:
remoteEntry.jsfetches appear before any remote content renders; shared dependency resolution produces a secondary wave of chunk downloads; multipleEvaluate Scripttasks appear in the Performance timeline before First Contentful Paint. - Compare
Content DownloadvsScript Evaluationtiming. A well-configured islands page shifts evaluation entirely past Largest Contentful Paint.
Key Metrics to Track
| Metric | Islands target | MFE typical range | Measurement tool |
|---|---|---|---|
| JS payload (gzipped) | < 50 KB on first load | 150β400 KB (shell + shared) | Network tab, bundlesize CI check |
| Time to Interactive | < 1.5 s (mobile, Slow 4G) | 2.5β5 s | Lighthouse mobile |
| Total Blocking Time | < 200 ms | 400β900 ms | Lighthouse mobile |
| INP (p75) | < 100 ms | 200β500 ms | CrUX or web-vitals library |
| Hydration delta per island | < 20 ms | N/A | performance.measure spans |
Failure Modes
1. Over-Fragmenting Islands β Coordination Overhead
Symptom: Dozens of tiny islands each firing their own client:visible observer cause a burst of concurrent hydration tasks when the user scrolls, producing an INP spike identical to full-hydration.
Fix: Group tightly coupled interactive components into a single island boundary:
---
// BAD: three separate islands with three separate observers and three JS bundles
import FilterBar from '../components/FilterBar.tsx';
import SortControl from '../components/SortControl.tsx';
import PaginationNav from '../components/PaginationNav.tsx';
---
---
// GOOD: one island, one observer, one bundle β components share state natively
import TableControls from '../components/TableControls.tsx';
// TableControls renders FilterBar + SortControl + PaginationNav internally
---
2. Race Conditions in Island Event Synchronization
Symptom: Island B subscribes to window events before Island A hydrates and dispatches, causing stale or missed updates on fast connections where hydration order varies.
Fix: Use a server-rendered URL param as the authoritative initial state so neither island depends on the other having hydrated first:
// Both islands read from URL on hydration β order-independent
const params = new URLSearchParams(window.location.search);
const initialRange = params.get('range')
? JSON.parse(decodeURIComponent(params.get('range')!))
: null;
3. Duplicate Framework Runtimes in MFE Shared Scope
Symptom: Two remotes declare react in their shared config but with mismatched requiredVersion ranges; Webpack falls back to loading both, doubling the React payload.
Fix: Pin all remotes to the same exact semver range in ModuleFederationPlugin and enforce it with a CI check:
// All remote webpack.config.js files must declare:
shared: {
react: { singleton: true, requiredVersion: '18.3.1', eager: false },
'react-dom': { singleton: true, requiredVersion: '18.3.1', eager: false },
},
// Add a pre-build script that asserts all remotes' package.json have "react": "18.3.1"
Decision Framework
Use this table to align the architectural choice with organizational reality:
| Criterion | Choose Micro-Frontends | Choose Islands Architecture |
|---|---|---|
| Team topology | Multiple autonomous squads with independent release cycles | Centralized or small team with coordinated deploys |
| Primary constraint | Developer velocity and deployment autonomy | Core Web Vitals and JavaScript payload budget |
| Content mix | Predominantly interactive SPA-style flows | Mostly static content with selective interactive widgets |
| State complexity | High β cross-app workflows, shared auth, global cart | Low-to-medium β server-synced, component-scoped state |
| Performance SLA | Acceptable TTI > 2.5 s on desktop | Strict TTI < 1.5 s on mobile Slow 4G |
| SSR/SSG requirement | Nice-to-have; complex to implement per-remote | First-class β static HTML ships immediately |
Incremental migration path (MFE β Islands):
- Identify static-dominant remotes. Profile each MFE with Lighthouse. Remotes with TBT > 300 ms and mostly static content are migration candidates.
- Extract interactive widgets as islands. Convert heavy client-rendered widgets (data grids, charts, search) to island components inside the existing MFE shell. Apply
client:visiblerather thanclient:loadfor anything below the fold. - Remove the orchestrator from static pages. Once a remoteβs interactive surface is covered by islands, the MFE orchestrator is no longer needed for that route. Switch to static generation with Astro or SvelteKit.
- Retain MFE boundaries for organizational seams. Keep Module Federation (or equivalent) at the macro level for teams that still need independent deployments. Embed islands within each MFE for micro-level performance recovery.
For the specific case of content-heavy SaaS products, islands architecture for content-heavy SaaS dashboards shows measured TTI reductions of 60β80 % against a Module Federation baseline on real dashboard pages.
Frequently Asked Questions
Can islands architecture and micro-frontends coexist in the same project?
Yes. A common hybrid keeps the MFE orchestrator for team-ownership boundaries β each squad deploys independently β while replacing heavy client-side widgets within each remote with islands that use compile-time hydration directives. This recovers Core Web Vitals without dismantling organizational structure.
Which approach scales better for large enterprise teams?
Micro-frontends scale better organizationally: each squad owns an independently deployed application. Islands scale better on the performance axis: they eliminate duplicate framework runtimes and defer JavaScript until needed. Large enterprises often combine both.
Do islands require a specific framework?
No. Astro client directives, Qwik resumable architecture, SvelteKit component islands, Fresh, and Marko all implement island-style partial hydration with different compile-time mechanisms. The pattern is framework-agnostic.
Related
- Understanding Partial Hydration β the foundational mechanism islands rely on to scope JavaScript execution to individual components.
- Progressive Enhancement in Modern Frameworks β how baseline HTML functionality is preserved when island hydration is deferred or fails.
- Cross-Boundary Prop Passing β server-to-client data transfer patterns that feed island initial state without a shared store.
- Event Delegation in Partially Hydrated Apps β implementing inter-island communication with native DOM events and global event buses.
- Islands Architecture for Content-Heavy SaaS Dashboards β a deep-dive into measured performance gains on real dashboard pages.
β Back to Core Islands Architecture & Hydration Models