Building Micro Frontends with Blazor WebAssembly and C#
Micro frontends apply the microservices idea to the frontend layer, splitting a monolithic UI into smaller, independently deployable pieces. If you're a .NET developer, Blazor WebAssembly lets you build micro frontends in C# and run them in the browser via WebAssembly.
What are micro frontends?
Micro frontends take the microservices idea and apply it to frontend development. Instead of one big monolithic frontend, you break it into smaller, independently deployable units. Each micro frontend is:
- Independently developed by different teams
- Deployed separately with its own release cycle
- Technology agnostic (though we'll focus on C#/Blazor)
- Loosely coupled with other frontend units
Why Blazor WebAssembly for micro frontends?
Blazor WebAssembly has real advantages for organizations already invested in .NET:
Key benefits
| Benefit | What you get |
|---|---|
| Unified development stack | Use C# across your entire application stack, from database to UI |
| Code reuse | Share models, validation logic, DTOs, and business logic between backend and frontend |
| Type safety | C#'s strong typing catches errors at compile time instead of runtime |
| Good tooling | Visual Studio's debugging, IntelliSense, and refactoring all work out of the box |
| WASM sandboxing | Each micro frontend runs in its own isolated WASM environment |
| Team productivity | .NET teams can be productive immediately without learning new languages |
Considerations
| Challenge | What it means |
|---|---|
| Initial payload size | Blazor WASM apps typically range from 2-7MB on first load |
| Cold start performance | WASM runtime initialization takes longer than plain JavaScript |
| Limited JavaScript ecosystem | Some interop required for JavaScript-specific libraries |
| SEO limitations | Client-side rendering affects search engine optimization |
When Blazor WASM micro frontends work well
This approach is a good fit for:
✅ Enterprise internal applications
- Administrative dashboards
- Back-office tools
- Employee portals
- Business intelligence platforms
✅ All-.NET technology stacks
- Organizations with primarily C# development teams
- Applications where backend and frontend share significant business logic
- Systems requiring strong type safety across the entire stack
✅ Complex business applications
- ERP systems
- CRM platforms
- Financial management tools
- Healthcare management systems
When this implementation is NOT ideal
There are cases where this approach should be avoided or at least reconsidered:
❌ Public-facing consumer applications
- E-commerce websites - SEO is critical for product discovery
- Marketing sites - First load performance directly impacts conversion rates
- Content websites - Search engine indexing requires server-side rendering
- Mobile-first applications - Large WASM payloads hurt mobile users on slow networks
❌ Mixed technology teams
- Polyglot development teams with JavaScript, Python, Java expertise
- Frontend teams specialized in React/Vue/Angular - forcing Blazor reduces productivity
- Organizations with existing JavaScript micro frontend infrastructure
- Teams requiring extensive JavaScript library ecosystem (D3.js, Three.js, etc.)
❌ Performance-critical scenarios
- Real-time trading platforms - every millisecond matters
- Gaming applications - WASM overhead can impact frame rates
- High-frequency data visualization - JavaScript often performs better for DOM manipulation
- Applications with strict bandwidth constraints
❌ Simple content-heavy applications
- Blogs and documentation sites - overkill for simple content delivery
- Static marketing pages - traditional HTML/CSS/JS is more appropriate
- Simple CRUD applications without complex business logic
⚠️ Important Consideration
If your application requires SEO optimization, fast initial load times, or serves public consumers on mobile devices, consider Blazor Server with SignalR or traditional JavaScript-based micro frontend solutions instead.
Implementation architecture
1. Module structure
// Shared contracts assembly
public interface IMicroFrontendHost
{
Task LoadModuleAsync(string moduleName);
Task GetSharedServiceAsync();
void PublishEvent(string eventName, object data);
Task RegisterModuleAsync(IMicroFrontend module);
}
public interface IMicroFrontend
{
string ModuleName { get; }
string Route { get; }
string DisplayName { get; }
string Version { get; }
Task InitializeAsync(IMicroFrontendHost host);
Task GetComponentTypeAsync();
}
// Real implementation from OrderManagement.Module
public class OrderManagementModule : IMicroFrontend
{
public string ModuleName => "OrderManagement";
public string Route => "/orders";
public string DisplayName => "Order Management";
public string Version => "1.0.0";
private IMicroFrontendHost? _host;
private ISharedUserContext? _userContext;
public async Task InitializeAsync(IMicroFrontendHost host)
{
_host = host;
_userContext = await host.GetSharedServiceAsync();
// Subscribe to relevant events
var eventBus = await host.GetSharedServiceAsync();
eventBus.Subscribe("product.selected", OnProductSelected);
// Publish module initialization event
host.PublishEvent("module.loaded", new ModuleLoadedEvent
{
ModuleName = ModuleName,
Version = Version,
LoadTime = TimeSpan.FromMilliseconds(50)
});
}
public Task GetComponentTypeAsync()
{
return Task.FromResult(typeof(Components.OrderManagementComponent));
}
}
2. Shared infrastructure
// Real MicroFrontendEventBus implementation with concurrent safety
public class MicroFrontendEventBus : IMicroFrontendEventBus
{
private readonly ConcurrentDictionary>> _handlers = new();
private readonly ILogger _logger;
public MicroFrontendEventBus(ILogger logger)
{
_logger = logger;
}
public void Subscribe(string eventName, Func handler)
{
_logger.LogDebug("Subscribing to event: {EventName}", eventName);
_handlers.AddOrUpdate(eventName,
new List> { data => handler((T)data) },
(key, existing) =>
{
existing.Add(data => handler((T)data));
return existing;
});
}
public async Task PublishAsync(string eventName, object data)
{
_logger.LogDebug("Publishing event: {EventName}", eventName);
if (_handlers.TryGetValue(eventName, out var handlers))
{
var tasks = handlers.Select(async handler =>
{
try
{
await handler(data);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling event {EventName}", eventName);
}
});
await Task.WhenAll(tasks);
}
}
public void Unsubscribe(string eventName)
{
_logger.LogDebug("Unsubscribing from event: {EventName}", eventName);
_handlers.TryRemove(eventName, out _);
}
}
3. Dynamic module loading
// Real ModuleLoader implementation with error handling and caching
public class ModuleLoader : IModuleLoader
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private readonly ConcurrentDictionary _loadedModules = new();
private readonly ConcurrentDictionary _loadTimes = new();
public ModuleLoader(HttpClient httpClient, ILogger logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task LoadModuleAsync(string moduleUrl) where T : class
{
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
_logger.LogInformation("Loading module from: {ModuleUrl}", moduleUrl);
if (_loadedModules.TryGetValue(moduleUrl, out var cachedAssembly))
{
_logger.LogDebug("Module already loaded, returning cached instance");
return CreateModuleInstance(cachedAssembly);
}
var assemblyBytes = await _httpClient.GetByteArrayAsync(moduleUrl);
var assembly = Assembly.Load(assemblyBytes);
_loadedModules[moduleUrl] = assembly;
_loadTimes[moduleUrl] = DateTime.UtcNow;
stopwatch.Stop();
_logger.LogInformation("Module loaded successfully in {ElapsedMs}ms", stopwatch.ElapsedMilliseconds);
return CreateModuleInstance(assembly);
}
catch (Exception ex)
{
stopwatch.Stop();
_logger.LogError(ex, "Failed to load module from {ModuleUrl} after {ElapsedMs}ms",
moduleUrl, stopwatch.ElapsedMilliseconds);
throw;
}
}
private T CreateModuleInstance(Assembly assembly) where T : class
{
var moduleType = assembly.GetTypes()
.FirstOrDefault(t => typeof(T).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface);
if (moduleType == null)
{
throw new InvalidOperationException(quot;No implementation of {typeof(T).Name} found in assembly");
}
return (T)Activator.CreateInstance(moduleType)!;
}
public async Task IsModuleLoadedAsync(string moduleName)
{
return _loadedModules.ContainsKey(moduleName);
}
public async Task UnloadModuleAsync(string moduleName)
{
_loadedModules.TryRemove(moduleName, out _);
_loadTimes.TryRemove(moduleName, out _);
_logger.LogInformation("Module {ModuleName} unloaded", moduleName);
}
}
Best practices for implementation
1. Establish clear boundaries
// Real shared contracts from the working implementation
namespace Shared.Contracts;
///
/// Interface for shared user context across modules
///
public interface ISharedUserContext
{
CurrentUser User { get; }
Task HasPermissionAsync(string permission);
Task GetUserTokenAsync();
}
///
/// Interface for module event communication
///
public interface IMicroFrontendEventBus
{
void Subscribe(string eventName, Func handler);
Task PublishAsync(string eventName, object data);
void Unsubscribe(string eventName);
}
// Real module implementation depending only on interfaces
public class ProductCatalogModule : IMicroFrontend
{
public string ModuleName => "ProductCatalog";
public string Route => "/products";
public string DisplayName => "Product Catalog";
public string Version => "1.0.0";
private ISharedUserContext? _userContext;
private IMicroFrontendEventBus? _eventBus;
public async Task InitializeAsync(IMicroFrontendHost host)
{
// Get shared services through dependency injection
_userContext = await host.GetSharedServiceAsync();
_eventBus = await host.GetSharedServiceAsync();
// Subscribe to events from other modules
_eventBus.Subscribe("order.created", OnOrderCreated);
// Publish module loaded event
host.PublishEvent("module.loaded", new ModuleLoadedEvent
{
ModuleName = ModuleName,
Version = Version,
LoadTime = TimeSpan.FromMilliseconds(75)
});
}
private async Task OnOrderCreated(object orderData)
{
// Handle order created event - update product inventory, etc.
var hasPermission = await _userContext?.HasPermissionAsync("products.update");
if (hasPermission == true)
{
// Process the order event
}
}
public Task GetComponentTypeAsync()
{
return Task.FromResult(typeof(Components.ProductCatalogComponent));
}
}
2. Implement error boundaries
// Real ModuleErrorBoundary component from the working solution
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Logging
@inject ILogger Logger
@inject IMicroFrontendEventBus EventBus
@if (hasError)
{
⚠️ Module Error
Module: @ModuleName
Error: @errorMessage
🔄 Retry
✖️ Dismiss
}
else
{
@ChildContent
}
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string ModuleName { get; set; } = "Unknown";
private bool hasError = false;
private string errorMessage = string.Empty;
private Exception? lastException;
public void ProcessErrorFromException(Exception exception)
{
hasError = true;
errorMessage = exception.Message;
lastException = exception;
// Log the error
Logger.LogError(exception, "Error in module {ModuleName}", ModuleName);
// Notify other modules of the failure
EventBus.PublishAsync("module.error", new ModuleErrorEvent
{
ModuleName = ModuleName,
ErrorMessage = exception.Message,
Timestamp = DateTime.UtcNow
});
StateHasChanged();
}
private async Task RetryOperation()
{
hasError = false;
errorMessage = string.Empty;
lastException = null;
StateHasChanged();
}
private void ClearError()
{
hasError = false;
errorMessage = string.Empty;
lastException = null;
StateHasChanged();
}
}
3. Optimize loading strategies
// Real dependency injection setup from Program.cs
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add("#app");
builder.RootComponents.Add("head::after");
// Register HTTP client
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// Register shared services for micro frontend infrastructure
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
// Register logging for debugging and monitoring
builder.Services.AddLogging();
await builder.Build().RunAsync();
// Progressive module loading with real implementation
public class MicroFrontendHost : IMicroFrontendHost
{
private readonly IServiceProvider _serviceProvider;
private readonly IMicroFrontendEventBus _eventBus;
private readonly IModuleLoader _moduleLoader;
private readonly Dictionary _registeredModules = new();
public async Task LoadModuleAsync(string moduleName)
{
if (_registeredModules.ContainsKey(moduleName))
{
_logger.LogDebug("Module {ModuleName} already loaded", moduleName);
return;
}
try
{
var moduleUrl = quot;/_content/{moduleName}/{moduleName}.dll";
var module = await _moduleLoader.LoadModuleAsync(moduleUrl);
if (module != null)
{
await RegisterModuleAsync(module);
await module.InitializeAsync(this);
PublishEvent("module.loaded", new ModuleLoadedEvent
{
ModuleName = moduleName,
Version = module.Version,
LoadTime = stopwatch.Elapsed
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load module {ModuleName}", moduleName);
PublishEvent("module.error", new ModuleErrorEvent
{
ModuleName = moduleName,
ErrorMessage = ex.Message
});
throw;
}
}
}
Real-world use cases
Case study 1: Enterprise resource planning (ERP) system
Scenario: A large manufacturing company needs an ERP system with modules for inventory, HR, finance, and production.
Why Blazor WASM micro frontends work well here:
- Internal users don't require SEO optimization
- Complex business logic benefits from C# type safety
- Shared data models across all modules reduce development time
- Different teams can work on HR vs. Finance modules independently
- Security benefits from WASM sandboxing
Case study 2: Healthcare management platform
Scenario: A hospital system requires patient management, scheduling, billing, and clinical modules.
Benefits:
- HIPAA compliance is easier with consistent .NET security practices
- Complex calculations for billing and clinical data use C# strengths
- Real-time updates between modules using SignalR integration
- Audit trails are consistent across all modules
Case study 3: Financial services dashboard
Scenario: A bank needs separate modules for account management, loan processing, compliance, and reporting.
Why this approach works:
- Regulatory compliance benefits from type-safe validation
- Complex financial calculations are more reliable in C#
- Legacy system integration is simplified with .NET interop
- Performance for number-crunching operations
Performance optimization strategies
1. Assembly trimming and project configuration
net8.0
enable
enable
service-worker-assets.js
true
link
false
2. Progressive loading
// Real implementation from the working solution
public class ProgressiveModuleLoader
{
private readonly IMicroFrontendHost _host;
private readonly ILogger _logger;
public ProgressiveModuleLoader(IMicroFrontendHost host, ILogger logger)
{
_host = host;
_logger = logger;
}
public async Task LoadCriticalModulesAsync()
{
_logger.LogInformation("Loading critical modules...");
// Load essential modules first - these are needed for basic functionality
var criticalModules = new[]
{
"Navigation", // Core navigation functionality
"UserProfile", // User authentication and profile
"ErrorHandler" // Error boundary and recovery
};
var loadTasks = criticalModules.Select(async module =>
{
try
{
await _host.LoadModuleAsync(module);
_logger.LogDebug("Critical module {ModuleName} loaded successfully", module);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load critical module {ModuleName}", module);
// Critical modules failures should be handled gracefully
}
});
await Task.WhenAll(loadTasks);
_logger.LogInformation("Critical modules loading completed");
}
public async Task LoadSecondaryModulesAsync()
{
_logger.LogInformation("Loading secondary modules on demand...");
// Load feature modules based on user permissions or route navigation
var secondaryModules = new[]
{
"OrderManagement", // Load when user navigates to /orders
"ProductCatalog", // Load when user navigates to /products
"Reports", // Load when user accesses reporting features
"Settings" // Load when user accesses settings
};
foreach (var module in secondaryModules)
{
try
{
// Load modules one by one to avoid overwhelming the browser
await _host.LoadModuleAsync(module);
_logger.LogDebug("Secondary module {ModuleName} loaded", module);
// Small delay between loads to improve perceived performance
await Task.Delay(100);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load secondary module {ModuleName}, continuing...", module);
// Secondary module failures shouldn't break the application
}
}
_logger.LogInformation("Secondary modules loading completed");
}
public async Task LoadModuleOnDemand(string moduleName)
{
_logger.LogInformation("Loading module {ModuleName} on demand", moduleName);
try
{
await _host.LoadModuleAsync(moduleName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load on-demand module {ModuleName}", moduleName);
throw; // Re-throw for UI to handle appropriately
}
}
}
3. Caching
// Enhanced caching service from the working implementation
public class ModuleCacheService
{
private readonly IJSRuntime _jsRuntime;
private readonly ILogger _logger;
private readonly ConcurrentDictionary _memoryCache = new();
public ModuleCacheService(IJSRuntime jsRuntime, ILogger logger)
{
_jsRuntime = jsRuntime;
_logger = logger;
}
public async Task CacheModuleAsync(string moduleName, byte[] moduleData, string version)
{
try
{
// Cache in browser storage for persistence across sessions
var cacheKey = quot;module_{moduleName}_{version}";
var base64Data = Convert.ToBase64String(moduleData);
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", cacheKey, base64Data);
// Also cache in memory for faster access during current session
_memoryCache[moduleName] = new ModuleCacheEntry
{
Data = moduleData,
Version = version,
CachedAt = DateTime.UtcNow,
LastAccessed = DateTime.UtcNow
};
_logger.LogDebug("Module {ModuleName} v{Version} cached successfully", moduleName, version);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to cache module {ModuleName}", moduleName);
}
}
public async Task GetCachedModuleAsync(string moduleName, string version)
{
// Check memory cache first (fastest)
if (_memoryCache.TryGetValue(moduleName, out var memoryEntry) &&
memoryEntry.Version == version)
{
memoryEntry.LastAccessed = DateTime.UtcNow;
_logger.LogDebug("Module {ModuleName} found in memory cache", moduleName);
return memoryEntry.Data;
}
try
{
// Check browser storage
var cacheKey = quot;module_{moduleName}_{version}";
var base64Data = await _jsRuntime.InvokeAsync("localStorage.getItem", cacheKey);
if (!string.IsNullOrEmpty(base64Data))
{
var moduleData = Convert.FromBase64String(base64Data);
// Update memory cache for faster future access
_memoryCache[moduleName] = new ModuleCacheEntry
{
Data = moduleData,
Version = version,
CachedAt = DateTime.UtcNow,
LastAccessed = DateTime.UtcNow
};
_logger.LogDebug("Module {ModuleName} found in browser storage", moduleName);
return moduleData;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to retrieve cached module {ModuleName}", moduleName);
}
_logger.LogDebug("Module {ModuleName} not found in cache", moduleName);
return null;
}
public async Task ClearExpiredCacheAsync(TimeSpan maxAge)
{
var cutoffTime = DateTime.UtcNow - maxAge;
var expiredEntries = _memoryCache
.Where(kvp => kvp.Value.LastAccessed kvp.Key)
.ToList();
foreach (var key in expiredEntries)
{
_memoryCache.TryRemove(key, out _);
_logger.LogDebug("Removed expired cache entry: {Key}", key);
}
// Also clear browser storage (would need more sophisticated implementation)
_logger.LogInformation("Cleared {Count} expired cache entries", expiredEntries.Count);
}
private class ModuleCacheEntry
{
public byte[] Data { get; set; } = Array.Empty();
public string Version { get; set; } = string.Empty;
public DateTime CachedAt { get; set; }
public DateTime LastAccessed { get; set; }
}
}
Deployment and DevOps considerations
Independent deployment pipeline
# Azure DevOps pipeline example
stages:
- stage: BuildModules
jobs:
- job: BuildOrderModule
steps:
- task: DotNetCoreCLI@2
inputs:
command: 'publish'
projects: 'OrderManagement.Module/OrderManagement.Module.csproj'
publishWebProjects: false
arguments: '--configuration Release --output $(Build.ArtifactStagingDirectory)/OrderModule'
Module versioning strategy
[assembly: AssemblyVersion("1.2.3")]
[assembly: ModuleVersion("1.2.3")]
public class ModuleRegistry
{
public async Task IsModuleCompatibleAsync(string moduleName, string version)
{
// Implement semantic versioning compatibility checks
var currentVersion = await GetCurrentModuleVersionAsync(moduleName);
return IsBackwardCompatible(currentVersion, version);
}
}
Complete working implementation
All the code examples above come from a working implementation of Blazor WebAssembly micro frontends. This is runnable code, not theoretical snippets.

✅ Complete Working Solution
Repository: BlazorMicroFrontends on GitHub
This repository contains a complete, working implementation with:
- ✅ Real OrderManagement and ProductCatalog modules - fully functional micro frontends
- ✅ Working event communication - modules communicate through the MicroFrontendEventBus
- ✅ Error boundaries and recovery - error handling with retry mechanisms
- ✅ Real-time event monitoring - see module interactions live in the Events page
- ✅ Complete setup instructions - get running in minutes with PowerShell commands
- ✅ Production-ready configuration - optimized builds and deployment settings
Happy Coding 👨💻