The mandate was to evolve our monolithic frontend into a set of independently deployable components—a micro-frontend architecture. The operational constraints, however, were significant: a small platform team, a strict budget limiting cloud spend, and an organizational aversion to the operational complexity of Kubernetes. This led us to a stack centered on Docker Swarm for its simplicity, managed by a declarative GitOps workflow, and fronted by a Tyk API Gateway for routing and security. The unresolved question was the frontend technology itself. The debate crystallized around two primary candidates: Vue.js, the established and familiar incumbent, and Solid.js, the high-performance challenger promising near-native speeds. This is the record of our analysis and the resulting architectural decisions.
Defining the Core Problem: A Framework for Decoupled UIs
Our primary technical challenge is not merely serving multiple JavaScript applications. It is creating a resilient, scalable, and maintainable platform where different product teams can build, test, and deploy their part of the user interface without impacting others.
The architecture must satisfy these criteria:
- Independent Deployability: A change to the “product recommendations” widget must not require a full redeployment of the “user account” page.
- Technological Heterogeneity: The platform should not enforce a single framework. A team should be able to choose the best tool for their specific problem domain.
- Simplified Operations: The underlying infrastructure must be manageable by a small team. This is the primary driver for choosing Docker Swarm over Kubernetes.
- Centralized Governance: While deployments are independent, concerns like authentication, rate-limiting, and routing must be managed centrally. This is the role of the Tyk API Gateway.
The request lifecycle in this proposed architecture is straightforward.
sequenceDiagram participant User participant Tyk API Gateway participant Docker Swarm (Overlay Network) participant Vue MFE Service participant Solid MFE Service User->>Tyk API Gateway: GET /portal/account Tyk API Gateway->>Docker Swarm (Overlay Network): Route to 'vue-account-mfe' Docker Swarm (Overlay Network)->>Vue MFE Service: GET / Vue MFE Service-->>Docker Swarm (Overlay Network): HTML/JS/CSS for Account Docker Swarm (Overlay Network)-->>Tyk API Gateway: Response Tyk API Gateway-->>User: Response User->>Tyk API Gateway: GET /portal/live-ticker Tyk API Gateway->>Docker Swarm (Overlay Network): Route to 'solid-ticker-mfe' Docker Swarm (Overlay Network)->>Solid MFE Service: GET / Solid MFE Service-->>Docker Swarm (Overlay Network): HTML/JS/CSS for Ticker Docker Swarm (Overlay Network)-->>Tyk API Gateway: Response Tyk API Gateway-->>User: Response
Within this context, the choice between Vue.js and Solid.js becomes a decision about trade-offs in performance, developer experience, ecosystem maturity, and long-term maintainability for the individual micro-frontend services.
Analysis of Solution A: Vue.js - The Mature Incumbent
Vue.js is the devil we know. Our teams are familiar with it, and its ecosystem is vast. For a complex, feature-rich micro-frontend, such as a user settings dashboard, Vue provides a robust foundation.
Strengths
- Ecosystem & Tooling: The availability of mature libraries for state management (Pinia), routing (Vue Router), and UI components (Vuetify, PrimeVue) drastically reduces development time.
- Developer Experience: The learning curve is gentle, especially with the Composition API, which promotes better logic reuse and organization. Single File Components (
.vue
files) are a proven and effective way to colocate template, script, and styles. - Stability & Community: Vue has a long track record in production. Finding solutions to common problems is trivial, and hiring developers with Vue experience is straightforward.
Weaknesses
- Performance Ceiling: The Virtual DOM, while effective, introduces an abstraction layer that has inherent overhead. For UIs requiring extremely frequent updates or running on low-powered devices, this can become a bottleneck.
- Bundle Size: The Vue runtime, while not massive, contributes to the initial load time. In a micro-frontend world where a user might load several “apps” on a single page, the cumulative size of multiple framework runtimes can be substantial.
- Reactivity Nuances: A common mistake, particularly for those new to the Composition API, is mishandling reactivity. Incorrectly destructuring
props
or losing reactivity withref
andreactive
can lead to subtle and hard-to-debug issues.
Implementation Example: A Product Card Micro-Frontend in Vue.js
Let’s consider a product-card
component. It fetches its own data, manages its internal state (e.g., whether the “add to cart” button is in a loading state), and emits events.
src/components/ProductCard.vue
<template>
<div class="product-card" :class="{ 'out-of-stock': !product.inStock }">
<div class="product-image">
<img :src="product.imageUrl" :alt="product.name" />
</div>
<div class="product-details">
<h3>{{ product.name }}</h3>
<p class="price">{{ formatCurrency(product.price) }}</p>
<p class="description">{{ product.description }}</p>
<button
@click="handleAddToCart"
:disabled="!product.inStock || isLoading"
>
<span v-if="isLoading">Adding...</span>
<span v-else>Add to Cart</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useProductStore } from '../stores/product';
import { formatCurrency } from '../utils/formatters';
const props = defineProps({
productId: {
type: String,
required: true
}
});
const productStore = useProductStore();
const product = ref({});
const isLoading = ref(false);
const error = ref(null);
// Fetch data when component is mounted
onMounted(async () => {
try {
const data = await productStore.fetchProduct(props.productId);
product.value = data;
} catch (err) {
console.error(`Failed to fetch product ${props.productId}:`, err);
error.value = 'Could not load product details.';
}
});
// A common pitfall is to make this async function directly the click handler.
// This can lead to race conditions if the user clicks multiple times.
// A loading state flag is the correct, pragmatic approach.
async function handleAddToCart() {
if (!product.value.id || isLoading.value) return;
isLoading.value = true;
try {
// This would typically call a store action that makes an API call.
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate network latency
console.log(`Added product ${product.value.id} to cart.`);
// Emitting an event for the parent application to listen to.
// This is a key communication mechanism in a micro-frontend setup.
window.dispatchEvent(new CustomEvent('mfe:addToCart', { detail: { productId: product.value.id } }));
} catch (err) {
console.error('Error adding to cart:', err);
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
/* Scoped styles are critical for MFE to avoid CSS conflicts. */
.product-card {
border: 1px solid #ccc;
border-radius: 8px;
overflow: hidden;
width: 300px;
}
.out-of-stock {
opacity: 0.6;
}
/* ... other styles */
</style>
Dockerfile for the Vue MFE
This is a production-grade, multi-stage Dockerfile that creates a minimal final image using Nginx.
# Stage 1: Build the application
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files and install dependencies
# This layer is cached if package files don't change
COPY package*.json ./
RUN npm ci
# Copy the rest of the application source code
COPY . .
# Build for production with minification
RUN npm run build
# Stage 2: Create the final production image
FROM nginx:1.25-alpine
# Set up a non-root user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Copy the built assets from the builder stage
COPY /app/dist /usr/share/nginx/html
# Copy a custom nginx configuration
# This is crucial for handling client-side routing in SPAs
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80
EXPOSE 80
# The default NGINX command is `nginx -g 'daemon off;'` which is what we want.
nginx.conf
This configuration is essential for any Single Page Application (SPA), ensuring that refreshing the page on a client-side route (/product/123
) doesn’t result in a 404 from the server.
server {
listen 80;
server_name localhost;
# Root directory for the application
root /usr/share/nginx/html;
index index.html;
# Log configuration for better debugging in production
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
location / {
# Try to serve the requested file directly, then as a directory,
# and finally fall back to index.html for SPA routing.
try_files $uri $uri/ /index.html;
}
# Optional: Aggressive caching for static assets
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg)$ {
expires 1y;
add_header Cache-Control "public";
}
}
The Vue solution is solid, predictable, and prioritizes developer productivity over raw performance. In a real-world project, this is often the most pragmatic choice for complex UIs where business logic is the primary challenge.
Analysis of Solution B: Solid.js - The Performance-Focused Challenger
Solid.js represents a different philosophy. By eschewing the Virtual DOM and compiling JSX to highly optimized, direct DOM manipulation calls, it achieves performance that is difficult to match. The reactivity model, based on signals, is also conceptually simpler, though it requires a different mental model.
Strengths
- Exceptional Performance: The lack of a VDOM means updates are surgical. For data-heavy, real-time UIs (e.g., a stock ticker, a live dashboard), the performance gain is not just measurable but perceptible.
- Tiny Bundle Size: The runtime is minuscule. This is a significant advantage in a micro-frontend architecture, reducing the cumulative payload size and improving initial page load times.
- Fine-Grained Reactivity: The signal-based system is explicit and powerful. It avoids the entire component re-rendering by default, which can be a source of performance issues in other frameworks.
Weaknesses
- Ecosystem Immaturity: The ecosystem is young. Finding battle-tested component libraries or specialized tools can be challenging. Often, we would have to build these ourselves or wrap existing vanilla JS libraries.
- Learning Curve: While the API surface is small, understanding the rules of reactivity is critical. For example, a common mistake is destructuring props, which breaks reactivity because it disconnects the value from the reactive signal. Developers must learn to think in terms of signals and effects.
- Hiring & Team Onboarding: Finding developers with production experience in Solid.js is more difficult than finding Vue developers. This is a real-world constraint that impacts project timelines and costs.
Implementation Example: The Same Product Card in Solid.js
Here’s how the same component would be implemented in Solid.js. The structure is similar, but the reactivity primitives are different.
src/components/ProductCard.jsx
import { createSignal, createResource, onMount } from 'solid-js';
import { formatCurrency } from '../utils/formatters';
// In a real app, this would be in a shared store or service module
const fetchProduct = async (productId) => {
// Simulating an API call
const response = await new Promise(resolve => setTimeout(() => {
resolve({
id: productId,
name: `Solid Product ${productId}`,
price: 99.99,
description: 'A high-performance product.',
imageUrl: '/placeholder.jpg',
inStock: true
});
}, 500));
return response;
};
export default function ProductCard(props) {
// `createResource` is the canonical way to handle async data loading in Solid.
// It provides `loading`, `error`, and `latest` states out of the box.
const [product] = createResource(() => props.productId, fetchProduct);
const [isAdding, setIsAdding] = createSignal(false);
const handleAddToCart = async () => {
if (isAdding() || !product()?.id) return;
setIsAdding(true);
try {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`Added product ${product().id} to cart.`);
// Dispatching a global event remains a valid cross-framework communication strategy.
window.dispatchEvent(new CustomEvent('mfe:addToCart', { detail: { productId: product().id } }));
} catch (err) {
console.error('Error adding to cart:', err);
} finally {
setIsAdding(false);
}
};
return (
// The <Show> component is Solid's way of doing conditional rendering
// without re-creating DOM elements unnecessarily.
<Show when={!product.loading && product()} fallback={<div>Loading product...</div>}>
<div class="product-card" classList={{ 'out-of-stock': !product().inStock }}>
<div class="product-image">
<img src={product().imageUrl} alt={product().name} />
</div>
<div class="product-details">
<h3>{product().name}</h3>
{/* Note the function call syntax `product().price`. This is how Solid
accesses the value of a signal or resource to track dependencies. */}
<p class="price">{formatCurrency(product().price)}</p>
<p class="description">{product().description}</p>
<button
onClick={handleAddToCart}
disabled={!product().inStock || isAdding()}
>
<Show when={isAdding()} fallback={<span>Add to Cart</span>}>
<span>Adding...</span>
</Show>
</button>
</div>
</div>
</Show>
);
}
The Dockerfile
and nginx.conf
for the Solid.js micro-frontend would be virtually identical to the Vue.js version, as they both compile down to static HTML, JS, and CSS files that need to be served by a web server. The difference lies entirely in the generated JavaScript bundle’s size and performance characteristics.
The Final Choice: A Polyglot Architecture Driven by Use Case
After weighing the pros and cons, we decided against mandating a single framework. The operational overhead of supporting two frameworks is a small price to pay for the flexibility of using the right tool for the job. Our final decision is to officially support both Vue.js and Solid.js, with clear guidelines on when to use each.
Rationale:
- Vue.js for Complexity and Features: For large, complex micro-frontends like user dashboards, admin panels, or multi-step forms, Vue.js is the default choice. Here, developer productivity, a rich ecosystem of UI components, and mature state management patterns (Pinia) outweigh the need for micro-optimized performance.
- Solid.js for Performance-Critical UIs: For micro-frontends where performance is the primary concern—such as real-time data visualizations, frequently updated lists, or widgets embedded on high-traffic public pages—Solid.js is the preferred choice. Its minimal bundle size and exceptional runtime performance provide a tangible benefit to the end-user experience.
This polyglot approach is only feasible because our underlying platform, built on Docker Swarm and Tyk, is agnostic to the frontend technology.
Core Implementation of the Platform
The platform itself is defined by two key configuration files.
docker-compose.yml
for Docker Swarm Stack Deployment
This file defines the services that make up our platform. It’s deployed to the Swarm manager using docker stack deploy -c docker-compose.yml mfe-platform
.
version: '3.8'
services:
# The central routing and governance layer
tyk-gateway:
image: tykio/tyk-gateway:v5.0.0
ports:
- "8080:8080"
networks:
- mfe-net
volumes:
- ./tyk/tyk.conf:/opt/tyk-gateway/tyk.conf
- ./tyk/apps:/opt/tyk-gateway/apps
- ./tyk/policies:/opt/tyk-gateway/policies
environment:
- TYK_GW_SECRET=a-strong-secret
# In a real setup, healthchecks and restart policies are critical
deploy:
replicas: 2
restart_policy:
condition: on-failure
# Example Vue.js Micro-Frontend
vue-product-card-mfe:
image: my-registry/vue-product-card-mfe:latest
networks:
- mfe-net
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
# Example Solid.js Micro-Frontend
solid-ticker-mfe:
image: my-registry/solid-ticker-mfe:latest
networks:
- mfe-net
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
networks:
mfe-net:
driver: overlay
attachable: true
Tyk API Definition for Routing
This JSON configuration, placed in the tyk/apps
directory, tells Tyk how to route incoming requests to the correct Docker Swarm service. Tyk uses Swarm’s built-in DNS to resolve service names (e.g., vue-product-card-mfe
).
{
"name": "Micro-Frontend Portal",
"api_id": "mfe-portal-api",
"org_id": "1",
"use_keyless": true,
"active": true,
"version_data": {
"not_versioned": true,
"versions": {
"Default": {
"name": "Default",
"use_extended_paths": true
}
}
},
"proxy": {
"listen_path": "/portal/",
"strip_listen_path": true
},
"extended_paths": {
"url_rewrites": [
{
"path": "product-vue/{rest}",
"method": "GET",
"match_pattern": "^/product-vue/(.*)",
"rewrite_to": "/$1",
"target_host": "http://vue-product-card-mfe"
},
{
"path": "ticker-solid/{rest}",
"method": "GET",
"match_pattern": "^/ticker-solid/(.*)",
"rewrite_to": "/$1",
"target_host": "http://solid-ticker-mfe"
}
]
}
}
A request to http://<tyk-gateway>:8080/portal/product-vue/assets/app.js
is internally routed by Tyk to http://vue-product-card-mfe/assets/app.js
, which Docker Swarm then load-balances to one of the running containers for that service.
Extensibility and Architectural Limitations
This architecture is deliberately simple. Its primary virtue is its low operational cost. However, this simplicity comes with trade-offs.
The system is extensible; adding a new micro-frontend built with Svelte, for instance, is a matter of adding a new service to the docker-compose.yml
and a new url_rewrite
rule to the Tyk API definition. The CI/CD pipeline for a new service would replicate the existing pattern: build, tag, push image, run docker service update
.
The limitations, however, are clear. Docker Swarm lacks the rich ecosystem of Kubernetes. There is no built-in service mesh for advanced traffic shaping or mTLS, no sophisticated scheduling policies, and no standard for custom controllers. Cross-MFE state management is also a significant challenge; while CustomEvent
works for simple notifications, more complex state sharing requires a dedicated library or reliance on browser storage mechanisms like localStorage
, which can become a bottleneck. Finally, the polyglot approach, while flexible, introduces a maintenance burden. The platform team must maintain build tooling, best practice documentation, and expertise for multiple frameworks, which can stretch a small team thin. This architecture is a pragmatic compromise, optimized for our specific constraints, not a universally ideal solution.