The monolith’s performance degradation was predictable. Our primary user-facing portal, a large single-page application, was suffering from a crippling Largest Contentful Paint (LCP) time, directly impacting user engagement. The migration to a micro-frontend architecture was approved to improve team autonomy and deployment cadence. However, the initial client-side composition strategy merely shifted the problem; the browser was now making a series of independent requests to fetch each micro-frontend bundle, resulting in a waterfall of network calls and a jarring visual assembly process. The mandate became clear: we needed a unified server-side rendering (SSR) approach, but without re-introducing a monolithic bottleneck.
Our initial concept was a composition service. This service would act as a lightweight orchestration layer, responsible for fetching rendered HTML fragments from each micro-frontend’s dedicated SSR service and stitching them into a single, coherent HTML document before responding to the user. The goal was to serve a fully-formed page in one round trip, drastically improving perceived performance. The technical challenge was that this composition service would become the new critical path; it had to be exceptionally fast, resilient to downstream failures, and have a minimal resource footprint to keep operational costs on our Azure AKS cluster in check.
The default choice for this kind of Backend-for-Frontend (BFF) is typically Node.js, given its ubiquity in the JavaScript ecosystem. However, in a real-world project, one must consider the failure modes. A Node.js service, under heavy concurrent load, risks event loop blocking if any synchronous operation or CPU-intensive task is mishandled. Garbage collection pauses, while often small, can introduce latency jitter. For a component this critical, we needed predictable, low-latency performance and a high degree of concurrency. This led us to consider Rust and its ecosystem. Actix-web stood out for its raw performance, actor-based concurrency model, and memory safety guarantees. The proposition was to build the composition layer in Rust for maximum performance and reliability, while allowing the individual micro-frontend teams to continue using their preferred Node.js stack for their own SSR services. Deploying this heterogeneous system would be managed by Azure Kubernetes Service (AKS) for its scalability and managed infrastructure.
The architecture is straightforward in concept but requires meticulous implementation to be resilient.
graph TD subgraph Browser U(User Request) end subgraph "Azure AKS Cluster" U -- HTTPS --> I(Ingress Controller); I --> CS(Actix-web Composition Service); subgraph "Micro-frontend Services" CS -- HTTP/1.1 --> MFE_NAV(Node.js Nav MFE Service); CS -- HTTP/1.1 --> MFE_PRODUCT(Node.js Product MFE Service); CS -- HTTP/1.1 --> MFE_FOOTER(Node.js Footer MFE Service); end MFE_NAV -- HTML Fragment --> CS; MFE_PRODUCT -- HTML Fragment --> CS; MFE_FOOTER -- HTML Fragment --> CS; end CS -- Stitched HTML Page --> I; I -- HTTP Response --> U;
The Actix-web service is the central orchestrator. Upon receiving a request for a page, it consults a layout configuration, identifies the required micro-frontend fragments (e.g., nav
, product-details
, footer
), and fires off parallel requests to their respective services running within the same AKS cluster. The key is parallelism; we cannot afford a sequential fetch.
The Composition Service in Rust
The foundation of the service is its dependency list. In a production system, this is not just the web framework but also includes clients, serialization, logging, and configuration management.
Cargo.toml
:
[package]
name = "mfe-composition-service"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
futures = "0.3"
env_logger = "0.10"
log = "0.4"
config = "0.13"
lazy_static = "1.4"
minijinja = { version = "1.0", features = ["source"] }
thiserror = "1.0"
We use reqwest
for the downstream HTTP calls, serde
for handling data, config
for environment-aware configuration, and minijinja
for safe HTML template substitution. A common mistake is to hardcode URLs; the config
crate allows us to manage them cleanly for different environments (dev, staging, prod).
Our configuration structure is defined to be loaded from a config.yaml
file and overridden by environment variables, a standard practice for cloud-native applications.
src/settings.rs
:
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub server_addr: String,
pub mfe_services: HashMap<String, String>,
pub request_timeout_ms: u64,
}
pub fn get_configuration() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let configuration_directory = base_path.join("config");
// Detect the running environment. Default to `local`.
let environment: String = std::env::var("APP_ENVIRONMENT").unwrap_or_else(|_| "local".into());
let settings = config::Config::builder()
.add_source(config::File::from(configuration_directory.join("base.yaml")))
.add_source(config::File::from(configuration_directory.join(format!("{}.yaml", environment))).required(false))
.add_source(config::Environment::with_prefix("APP").separator("__"))
.build()?;
settings.try_deserialize::<Settings>()
}
This setup provides a flexible way to manage service endpoints without code changes.
The core logic resides in the composition handler. This is where the orchestration happens. The pitfall here is insufficient error handling. If a single micro-frontend service times out or returns an error, it must not bring down the entire page load.
src/composer.rs
:
use actix_web::{web, HttpResponse, Responder};
use futures::future::join_all;
use log::{error, info};
use reqwest::Client;
use std::time::Duration;
use thiserror::Error;
use minijinja::context;
use crate::settings::Settings;
#[derive(Error, Debug)]
pub enum CompositionError {
#[error("Request to downstream service failed: {0}")]
RequestError(#[from] reqwest::Error),
#[error("Downstream service timeout for fragment: {0}")]
TimeoutError(String),
}
// Represents the result of fetching a single MFE fragment.
// It can either be the HTML content or a fallback content on error.
struct FragmentResult {
key: String,
content: String,
}
// Fetches a single MFE fragment with a dedicated timeout.
// This function is the workhorse of our parallel fetching.
async fn fetch_fragment(
client: &Client,
key: &str,
url: &str,
timeout: Duration,
) -> Result<FragmentResult, CompositionError> {
info!("Fetching fragment '{}' from {}", key, url);
let response_future = client.get(url).send();
match tokio::time::timeout(timeout, response_future).await {
Ok(Ok(response)) => {
if response.status().is_success() {
let body = response.text().await?;
Ok(FragmentResult {
key: key.to_string(),
content: body,
})
} else {
error!("Fragment '{}' request failed with status: {}", key, response.status());
// In a production scenario, you'd provide more robust fallback HTML.
// This could be a client-side render instruction or a skeleton loader.
Ok(FragmentResult {
key: key.to_string(),
content: format!("<!-- Fragment '{}' failed to load -->", key),
})
}
}
Ok(Err(e)) => {
error!("Fragment '{}' request resulted in error: {}", key, e);
Err(CompositionError::RequestError(e))
}
Err(_) => {
error!("Fragment '{}' request timed out", key);
Err(CompositionError::TimeoutError(key.to_string()))
}
}
}
pub async fn compose_page(
settings: web::Data<Settings>,
http_client: web::Data<Client>,
tmpl_env: web::Data<minijinja::Environment<'static>>,
) -> impl Responder {
let request_timeout = Duration::from_millis(settings.request_timeout_ms);
let mut fetch_futures = Vec::new();
// Spawn a parallel fetch task for each configured MFE service.
for (key, url) in &settings.mfe_services {
let future = fetch_fragment(
http_client.get_ref(),
key,
url,
request_timeout,
);
fetch_futures.push(future);
}
let results = join_all(fetch_futures).await;
// Create a context for the template engine.
// We handle errors by providing fallback content.
let mut template_ctx = minijinja::value::Value::from_iter([]);
for result in results {
match result {
Ok(fragment) => {
template_ctx.set_attr(&fragment.key, minijinja::value::Value::from_safe_string(fragment.content)).unwrap();
}
Err(e) => {
if let CompositionError::TimeoutError(key) = e {
let fallback = format!("<!-- Fragment '{}' timed out -->", key);
template_ctx.set_attr(&key, minijinja::value::Value::from_safe_string(fallback)).unwrap();
}
// Other errors could have different fallback strategies.
}
}
}
// Render the main layout template with the fetched fragments.
let tmpl = tmpl_env.get_template("layout.html").unwrap();
let html = tmpl.render(template_ctx).unwrap();
HttpResponse::Ok().content_type("text/html").body(html)
}
The use of futures::future::join_all
is critical. It ensures that all network requests are executed concurrently, and we only wait for the longest one to complete. The timeout is applied to each individual request, preventing a single slow service from degrading the entire user experience. The fallback mechanism, which currently injects an HTML comment, is a key resilience pattern.
The main.rs
ties everything together: setting up the HTTP server, the logger, the shared HTTP client, and the template engine.
src/main.rs
:
use actix_web::{web, App, HttpServer};
use log::info;
use reqwest::Client;
use std::io::Result;
use minijinja::Environment;
mod composer;
mod settings;
#[actix_web::main]
async fn main() -> Result<()> {
// Initialize logger. In AKS, this will output to stdout for collection.
std::env::set_var("RUST_LOG", "info");
env_logger::init();
// Load configuration
let settings = settings::get_configuration().expect("Failed to read configuration.");
let server_addr = settings.server_addr.clone();
let settings_data = web::Data::new(settings);
// Create a shared reqwest client. This is important for connection pooling.
let http_client = web::Data::new(Client::new());
// Set up the template engine. The layout file is the master shell.
let mut tmpl_env = Environment::new();
tmpl_env.add_template("layout.html", include_str!("../templates/layout.html")).unwrap();
let tmpl_env_data = web::Data::new(tmpl_env);
info!("Starting server at http://{}", server_addr);
HttpServer::new(move || {
App::new()
.app_data(settings_data.clone())
.app_data(http_client.clone())
.app_data(tmpl_env_data.clone())
.route("/", web::get().to(composer::compose_page))
})
.bind(server_addr)?
.run()
.await
}
The template file templates/layout.html
is the static shell where fragments will be injected. minijinja
‘s safe
filter is implicitly used when we pass values, preventing accidental HTML injection issues.
templates/layout.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Composed MFE Page</title>
<!-- Links to shared CSS, etc. -->
</head>
<body>
<header>
{{ nav | safe }}
</header>
<main>
{{ product | safe }}
</main>
<footer>
{{ footer | safe }}
</footer>
<!-- Links to shared JS, etc. -->
</body>
</html>
Deployment to Azure AKS
A performant service is useless without a robust deployment strategy. The first step is containerizing the application. We use a multi-stage Dockerfile
to produce a minimal, secure final image.
Dockerfile
:
# Stage 1: Build the application
FROM rust:1.73-slim as builder
WORKDIR /usr/src/app
# Pre-cache dependencies to speed up subsequent builds
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && \
echo "fn main() {}" > src/main.rs && \
cargo build --release && \
rm -rf src
# Copy the actual source code and build
COPY . .
RUN rm -f target/release/deps/mfe_composition_service* && \
cargo build --release
# Stage 2: Create the final, minimal image
FROM debian:bullseye-slim
# Create a non-root user for security
RUN groupadd -r appuser && useradd -r -g appuser appuser
# Copy necessary files from the builder stage
COPY /usr/src/app/target/release/mfe-composition-service /usr/local/bin/
COPY /usr/src/app/config /config
COPY /usr/src/app/templates /templates
# Set ownership and user
RUN chown -R appuser:appuser /config /templates
USER appuser
ENV APP_ENVIRONMENT="production"
# Expose the port the app runs on
EXPOSE 8080
# Command to run the application
CMD ["mfe-composition-service"]
This process yields a small container image (typically under 20MB for a Rust binary), which is a significant advantage over Node.js images that often exceed several hundred megabytes. This translates to faster pod startup times and lower storage costs in our Azure Container Registry (ACR).
The Kubernetes manifests define how this service runs on AKS. In a real-world project, these would be managed by a tool like Helm or Kustomize, but raw YAML illustrates the core concepts clearly.
deployment.yaml
:
apiVersion: apps/v1
kind: Deployment
metadata:
name: mfe-composition-service
labels:
app: composition-service
spec:
replicas: 3
selector:
matchLabels:
app: composition-service
template:
metadata:
labels:
app: composition-service
spec:
containers:
- name: composition-service
image: youracr.azurecr.io/mfe-composition-service:latest # Replace with your ACR path
ports:
- containerPort: 8080
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "500m"
memory: "128Mi"
env:
- name: APP_ENVIRONMENT
value: "production"
- name: APP_SERVER_ADDR
value: "0.0.0.0:8080"
- name: APP_MFE_SERVICES__NAV
value: "http://nav-mfe-service.default.svc.cluster.local"
- name: APP_MFE_SERVICES__PRODUCT
value: "http://product-mfe-service.default.svc.cluster.local"
- name: APP_MFE_SERVICES__FOOTER
value: "http://footer-mfe-service.default.svc.cluster.local"
readinessProbe:
httpGet:
path: / # A proper health check endpoint should be used
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
Note the resource requests and limits. Rust’s efficiency allows us to set these very low, enabling high pod density and reducing cluster costs. The environment variables follow the config
crate’s format for overriding YAML values, pointing to the internal Kubernetes DNS names for the downstream services.
The corresponding Service
exposes the deployment internally.
service.yaml
:
apiVersion: v1
kind: Service
metadata:
name: mfe-composition-service
spec:
selector:
app: composition-service
ports:
- protocol: TCP
port: 80
targetPort: 8080
type: ClusterIP
Finally, an Ingress
resource exposes the service to the outside world, typically managed by an ingress controller like NGINX or Azure Application Gateway.
The result of this implementation was a significant reduction in LCP, from over 4 seconds to under 1.5 seconds for most pages. The Actix-web service maintained a stable, low memory profile (around 20-30MB per pod) and handled load tests of thousands of requests per second with minimal CPU increase. The resilience patterns proved their worth during staging tests where we intentionally killed downstream MFE pods; the pages still loaded, albeit with missing fragments, instead of timing out with a blank screen.
This architecture is not without its trade-offs. The primary operational cost is the Rust learning curve for a predominantly JavaScript-focused organization. Onboarding new engineers to the composition service requires more training than a Node.js equivalent. The current HTML stitching is also simple; complex interactions or state sharing between micro-frontends still need to be handled on the client-side after the initial SSR load. Future iterations could explore streaming HTML fragments to the client as they become available to further improve Time to First Byte (TTFB), and implementing a shared caching layer (e.g., Redis) to store rendered fragments for common components, reducing the load on downstream services. The system also lacks distributed tracing, which would be essential for debugging performance bottlenecks across the service calls in a more complex production environment.