Building Micro Frontends with Blazor WebAssembly and C#

By · · Technology

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:

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

✅ All-.NET technology stacks

✅ Complex business applications

When this implementation is NOT ideal

There are cases where this approach should be avoided or at least reconsidered:

❌ Public-facing consumer applications

❌ Mixed technology teams

❌ Performance-critical scenarios

❌ Simple content-heavy applications

⚠️ 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:

Case study 2: Healthcare management platform

Scenario: A hospital system requires patient management, scheduling, billing, and clinical modules.

Benefits:

Case study 3: Financial services dashboard

Scenario: A bank needs separate modules for account management, loan processing, compliance, and reporting.

Why this approach works:

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 👨‍💻