Island Architecture × Micro Frontend: A Structure Where More Teams Mean Smaller Bundles (Island Architecture × Micro Frontend)
Performance metrics getting worse after adopting micro frontends is more common than you might think. I experienced it firsthand — when our team count grew from 3 to 6, the initial JS bundle swelled to 2.8× its original size, and opening the browser network tab revealed React loading three times. We had gained team independence, but TTI (Time to Interactive) had actually increased. I kept wondering, "Is this really the right way?"
Island Architecture is an approach that directly confronts this paradox. Most of the page stays as static HTML, and JavaScript is selectively attached only to the "islands" that actually need interaction — things like a shopping cart, notification bell, or real-time stock indicator. Combining the team autonomy of micro frontends with the selective hydration of islands creates a structure where JavaScript delivered to the user doesn't grow as team count increases.
In this article, we'll examine how the two patterns interlock, how to implement them with the Astro + Module Federation combination, and — honestly — what pitfalls exist. Feel free to read through in order, from concepts to actual code to real-world anecdotes.
Core Concepts
Island Architecture — "Static by Default, JS Only Where Needed"
First proposed by Katie Sylor-Miller in 2020 and popularized by Jason Miller, this pattern has a simple philosophy: serve HTML quickly via SSR, but minimize the JavaScript delivered to the client. While traditional SSR/CSR apps hydrate the entire page, Island Architecture selectively processes only specific components.
Hydration: The process of attaching event listeners and state to static HTML generated by the server, enabling frameworks like React or Vue to take control. Think of it as "attaching muscles to a static skeleton."
There's a reason Astro is used as the de facto standard for this pattern. Directives like client:idle and client:visible let you specify "when to hydrate" on a per-component basis, and React, Vue, and Svelte can be mixed on the same page.
---
import SearchBar from './SearchBar.jsx' // React component
import CartBadge from './CartBadge.vue' // Vue component
import StaticNav from './StaticNav.astro' // No JS
---
<StaticNav /> <!-- Static HTML only, 0 bytes of JS -->
<SearchBar client:idle /> <!-- Hydrates when the browser is idle -->
<CartBadge client:visible /> <!-- Hydrates when entering the viewport -->Looking at the HTML output after rendering, <SearchBar> is wrapped in an <astro-island> custom element with metadata like data-astro-cid and data-component-url attached. Astro's runtime reads this metadata and executes hydration at the appropriate time.
Micro Frontend — Applying Backend MSA to the Frontend
Just as microservices split the backend into independent services, micro frontends break large web apps into units that can be independently developed and deployed by each team.
| Value | Description |
|---|---|
| Team autonomy | The payments team uses React, the recommendations team uses Svelte — each can choose their own stack |
| Independent deployment | Team A's deployment doesn't affect Team B's release |
| Clear ownership | When a bug appears, the responsible team's code can be identified immediately |
Those familiar with backend MSA will recognize these concepts, but the biggest difference when applying them to the frontend is "bundling." Unlike MSA where services communicate via HTTP, micro frontends ultimately run in the same browser tab, so how the code is combined matters. Module Federation handles this role — it dynamically loads remote modules at runtime to combine components built by separate teams on one page.
The Synergy When Two Patterns Meet
Honestly, at first I wondered, "Isn't this just forcing two things together?" But in practice, they complement each other by solving different problems.
| Pattern | Team Independence | Bundle Size Control | Fast TTI |
|---|---|---|---|
| Micro frontend only | Achieved | Difficult (duplication risk) | Difficult |
| Island Architecture only | Limited | Achieved | Achieved |
| Both combined | Achieved | Achieved | Achieved |
When island boundaries align with team boundaries, each team is responsible only for their own island, and users only download that team's code at the moment that island is actually needed. The potential for INP (Interaction to Next Paint) improvement comes from here — though actual numbers vary significantly based on the internal implementation of islands and network conditions, so it's more accurate to understand this as "creating a structural foundation for scaling teams without performance degradation" rather than "using this structure automatically makes things faster."
INP (Interaction to Next Paint): The responsiveness metric in Core Web Vitals. The time from user input to the next screen update. Under 200ms is good, 200–500ms needs improvement, over 500ms is poor.
Practical Application
Example 1: Integrating a React Team Component as an Island with Astro + Module Federation
This is the most realistic scenario. Using Astro as the shell app, the React team exposes components via Module Federation, and the Astro shell loads them at runtime and mounts them as islands.
React Team — Exposing the Cart App as a Remote Module:
// cart-app/vite.config.ts (React team)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@module-federation/vite'
export default defineConfig({
plugins: [
react(),
federation({
name: 'cartApp',
filename: 'remoteEntry.js',
exposes: {
'./Cart': './src/components/Cart.tsx',
},
shared: {
react: { singleton: true, requiredVersion: '>=18' },
'react-dom': { singleton: true, requiredVersion: '>=18' },
},
}),
],
build: { target: 'esnext' },
})Astro Shell — Wrapping the Remote Module in a Client Wrapper and Mounting it as an Island:
Module Federation's remote module loading happens in the browser runtime. Astro's frontmatter (---) executes at build or SSR time, so directly awaiting a remote module there may not actually work. Instead, use a client-only wrapper component, leveraging Module Federation's ability to intercept dynamic imports at runtime.
// shell/src/components/CartWrapper.tsx (Shell team)
import React, { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
// Module Federation intercepts the dynamic import at runtime and returns the remote module
const Cart = React.lazy(() =>
// @ts-ignore — path injected by MF at runtime
import('cartApp/Cart').catch(() => ({
default: () => <p>Cart is temporarily unavailable</p>,
}))
)
export default function CartWrapper() {
return (
<ErrorBoundary fallback={<p>There was a problem with the cart server</p>}>
<Suspense fallback={<p>Loading...</p>}>
<Cart />
</Suspense>
</ErrorBoundary>
)
}---
// shell/src/pages/index.astro
import StaticHeader from '../components/StaticHeader.astro'
import StaticFooter from '../components/StaticFooter.astro'
import CartWrapper from '../components/CartWrapper.tsx'
import RecommendWrapper from '../components/RecommendWrapper.tsx' // Vue team follows the same pattern
---
<html>
<body>
<StaticHeader /> <!-- No JS, renders immediately -->
<!-- Hydrates on viewport entry: no JS load before scrolling -->
<RecommendWrapper client:visible />
<!-- Hydrates when browser is idle: protects LCP -->
<CartWrapper client:idle />
<StaticFooter /> <!-- No JS -->
</body>
</html>| Code Point | Meaning |
|---|---|
singleton: true |
Loads React only once. Without this, two React instances run and hooks break |
requiredVersion: '>=18' |
Specifies version negotiation range. More flexible than pinning an exact version |
ErrorBoundary |
Isolates the page so it doesn't break entirely if the remote MFE server goes down |
client:idle |
Delays hydration until the main thread has spare capacity → protects LCP |
client:visible |
Skips JS loading entirely before scroll → reduces initial bundle |
Example 2: Distributed SSR Micro Frontend with Astro Server Islands
Each team runs an independent server, and the main page asynchronously renders each team's component in parallel as server islands. This can be used for scenarios where the ML team runs a separate recommendation engine server without affecting main page performance.
---
// shell/src/pages/product/[id].astro
import ProductInfo from '../components/ProductInfo.astro'
import RecommendIsland from '../components/RecommendIsland.astro'
import ReviewIsland from '../components/ReviewIsland.astro'
const { id } = Astro.params
---
<article>
<!-- Basic product info: static SSR, cacheable by CDN -->
<ProductInfo productId={id} />
<!-- Parallel rendering from ML team's recommendation server — delays don't block the rest of the page -->
<RecommendIsland server:defer userId={Astro.locals.userId} />
<!-- UGC team's review server, also processed in parallel -->
<ReviewIsland server:defer productId={id} />
</article>---
// components/RecommendIsland.astro — Owned by ML team, rendered on ML team's independent server
const { userId } = Astro.props
const recommendations = await fetchFromMLService(userId)
---
<section class="recommendations">
{recommendations.map(item => <ProductCard {...item} />)}
</section>
server:defer: An Astro Server Islands directive that separates the component into a distinct server request, asynchronously filling it in without blocking the main HTML response. Especially useful for separating non-cacheable content like personalized content onto independent servers. Even if the recommendation engine takes 500ms, product details are shown to the user immediately.
Example 3: E-commerce Team Structure — Aligning Island Boundaries with Team Boundaries
This is a challenge frequently encountered in practice — when the technical structure and organizational structure are misaligned, the benefits of micro frontends are cut in half. The hydration strategy for each island must align with that team's data characteristics and user behavior patterns to work properly.
| Island | Owning Team | Hydration Strategy | Reason |
|---|---|---|---|
| Product listing | Catalog team | Static HTML (no JS) | Search bot crawling + initial LCP optimization |
| Cart badge | Commerce team | client:load |
Needs to be interactive immediately on page entry |
| Recommendation widget | ML team | server:defer |
Personalized content + independent server operation |
| Stock display | Logistics team | client:visible |
Only needed after scrolling |
| Review section | UGC team | client:visible |
Only needed after scrolling |
Changes to the catalog team's code don't affect the commerce team's cart deployment. Even if the ML team changes the recommendation algorithm, the main page HTML response time stays the same. The core of this structure is that "team boundaries = island boundaries" align — when they don't, situations arise where Team A's changes require touching Team B's islands.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Performance + independence simultaneously | Combines the organizational benefits of micro frontends with the performance benefits of islands; user JS doesn't increase as teams grow |
| Progressive hydration | JS loads only for parts the user sees or interacts with, maximizing initial load speed |
| Framework independence | React, Vue, and Svelte can coexist on the same page; teams retain stack choice |
| Independent deployment | Each island can run its own CI/CD pipeline; Team A's deployment doesn't affect Team B |
| Module Federation 2.0 | Supports Vite and Rspack without Webpack dependency; build speed improves significantly with Rspack |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Complexity spike | Repositories, build pipelines, and deployment processes multiply by team count | Centralize management with Nx monorepo + Module Federation |
| State sharing between islands | Gets complex when different teams' islands need to sync shared state like cart quantities | Define inter-team event contracts with CustomEvent / BroadcastChannel / nanostores |
| Dependency version conflicts | Negotiation fails when host and remote require different versions of shared libraries | Use range specifiers like requiredVersion: '>=X' in shared, agree on team version policies |
| UI/UX inconsistency | Independent development per team leads to inconsistent button styles and interaction patterns | A shared design system and component library are essential |
| Host pollution | Islands can pollute global CSS and event listeners | Scope isolation with Shadow DOM or CSS Modules |
| Route-level limitations | Astro Server Islands suits component-level MFE; full route app integration is unsupported | Consider Single-SPA or Nginx-based routing for route-level integration |
Host Pollution: The phenomenon where a micro frontend overwrites the global scope (
window.foo = ...), global CSS (:root { --color: red }), or global event listeners, unintentionally affecting other islands or the shell.
The Most Common Mistakes in Practice
1. Configuring Module Federation without singleton: true
If you omit the shared configuration or leave out singleton: true during initial setup, two React instances run and you get "Invalid hook call" errors. The error message looks like a hook problem, so you spend a long time digging through component code only to discover the bundler configuration was the issue — I personally wasted half a day on this the first time. Once you know the cause, the fix is almost laughably simple, but building the habit of checking Module Federation configuration first will save you from this pain.
2. Trying to solve state sharing between islands by making the shell act as an intermediary
Situations arise where state needs to be shared between different teams' islands — like when the cart team's island changes a quantity and the header team's cart badge needs to update. Since props can't be passed directly between islands, putting intermediate logic in the Astro shell causes it to bloat over time. Explicitly defining inter-team event contracts with CustomEvent or BroadcastChannel, or using a framework-agnostic store like nanostores as a shared dependency, is much cleaner.
3. Making every component an island
Overusing client:load is no different from traditional CSR. The key is to ask "Does this component truly need to be interactive immediately on first render?" — and if not, to delay it with client:visible or client:idle, or leave it as static HTML. More islands isn't better; islands in precisely the right places is better.
Closing Thoughts
There are 3 steps you can start with right now:
-
You can create a project with
pnpm create astro@latest my-shelland try mounting an existing React component withclient:idle. Checking the browser network tab to see when JS files are requested makes the difference in hydration timing visible. -
You can add the
@module-federation/viteplugin and expose one existing app as a remote module. It's recommended to start with the minimal configuration: register just one component inexposesand load it with theReact.lazy(() => import('appName/Component'))pattern. -
You can clearly define which component belongs to which team's ownership, then align those boundaries with island boundaries. When you reach the point where "who should fix this bug?" can be answered instantly, you'll feel the moment technology and organization have aligned.
"Performance gets worse as teams grow" is a structural problem. The moment island boundaries align with team boundaries, adding more teams can actually improve the user experience.
References
- An Approach to Astro's Server Islands for Fast Micro-Frontends | talent500
- Composing micro-frontends with Astro's Server Islands | Medium
- Islands Architecture | Astro Official Docs
- Server Islands | Astro Official Docs
- Microfrontends Island Architecture | Better Programming
- Islands Architecture | patterns.dev
- Module Federation 2.0: webpack vs Rspack vs Vite 2026 | PkgPulse
- Module Federation 2.0 Reaches Stable Release | InfoQ
- Seamless Integration of React and Vue in Astro Using Module Federation | Leapcell
- Islands Architecture | Jason Miller
- Understanding Micro Frontends | Frontend Mastery
- 대규모 프론트엔드 아키텍처의 새로운 패러다임 | 올리브영 테크블로그