ying either framework correctly, followed by architectural rationale.
Step 1: Define the Interaction Topology
Classify your application's primary interaction pattern:
- Request-Response / SEO-First: MVC
- State-Rich / Real-Time / Offline-Adjacent: Blazor (Server or WASM)
- Hybrid: MVC for public/marketing, Blazor for authenticated dashboards
Step 2: Implement MVC (Request-Response Pattern)
MVC should remain stateless. Avoid client-side state synchronization unless explicitly required.
// Controllers/ProductsController.cs
public class ProductsController : Controller
{
private readonly IProductRepository _repo;
private readonly ILogger<ProductsController> _logger;
public ProductsController(IProductRepository repo, ILogger<ProductsController> logger)
{
_repo = repo;
_logger = logger;
}
[HttpGet]
public async Task<IActionResult> Index(int page = 1, int size = 20)
{
var paged = await _repo.GetPagedAsync(page, size);
return View(paged);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(ProductViewModel model)
{
if (!ModelState.IsValid) return View("Index", model);
await _repo.AddAsync(model.MapToEntity());
TempData["Success"] = "Product created.";
return RedirectToAction("Index");
}
}
Key architectural decisions:
- Use
[ValidateAntiForgeryToken] and TempData for flash messages to maintain statelessness
- Implement server-side pagination to prevent memory pressure
- Keep views strictly presentational; move business logic to services
Step 3: Implement Blazor (Component-Driven Pattern)
Blazor requires explicit state management and lifecycle awareness. Use @inject for DI, EventCallback for parent-child communication, and IJSRuntime only when bridging to native browser APIs.
<!-- Pages/Inventory.razor -->
@page "/inventory"
@inject IInventoryService InventoryService
@inject ILogger<Inventory> Logger
<PageTitle>Inventory Dashboard</PageTitle>
@if (_items is null)
{
<p><em>Loading inventory data...</em></p>
}
else
{
<table class="table">
<thead><tr><th>SKU</th><th>Quantity</th><th>Status</th></tr></thead>
<tbody>
@foreach (var item in _items)
{
<tr>
<td>@item.Sku</td>
<td>@item.Quantity</td>
<td>
<button @onclick="() => AdjustQuantity(item)">Adjust</button>
</td>
</tr>
}
</tbody>
</table>
}
@code {
private List<InventoryItem>? _items;
private bool _isUpdating;
protected override async Task OnInitializedAsync()
{
try
{
_items = await InventoryService.GetActiveAsync();
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load inventory");
}
}
private async Task AdjustQuantity(InventoryItem item)
{
if (_isUpdating) return;
_isUpdating = true;
try
{
item.Quantity += 1;
await InventoryService.UpdateAsync(item);
StateHasChanged();
}
finally
{
_isUpdating = false;
}
}
}
Key architectural decisions:
- Use
StateHasChanged() only when UI must reflect non-event-driven state changes
- Implement debouncing for rapid user inputs to prevent SignalR queue saturation
- Prefer
IInventoryService over direct HttpClient calls to enable testability and caching layers
Step 4: Shared Architecture & API-First Decisions
Regardless of UI framework, decouple presentation from domain logic:
- Shared Contracts: Place DTOs, validation rules, and domain entities in a
.Contracts or .Domain project
- API Gateway: Expose minimal endpoints; let MVC use server-side HTTP calls and Blazor use
HttpClient or SignalR hubs
- Routing Boundaries: Use
/app/* for Blazor routes and /admin/* or /public/* for MVC to prevent routing conflicts in hybrid deployments
Pitfall Guide
1. Treating Blazor Server as a Stateless MVC Replacement
Blazor Server maintains a circuit per user connection. Assuming it behaves like MVC's request-response model leads to memory leaks when circuits aren't disposed properly. Always configure CircuitOptions and implement reconnection handlers. Use PersistentComponentState for prerendering to avoid blank screens.
2. Over-Fetching in Blazor Components
Fetching entire datasets on component initialization ignores network and memory constraints. Implement pagination, virtualization (<Virtualize>), and server-side filtering. Cache aggressively using IMemoryCache or distributed Redis when data changes infrequently.
3. DOM Manipulation in Blazor
Importing jQuery or direct element.innerHTML assignments breaks Blazor's diffing algorithm. Blazor owns the DOM tree it renders. Use @ref for specific element references and IJSRuntime.InvokeVoidAsync only for non-Blazor-managed interactions. Prefer CSS classes and conditional rendering over manual DOM updates.
4. Ignoring Blazor WASM Cold-Start Latency
Downloading the .NET runtime and app assemblies takes 600–900ms on average connections. Failing to implement progressive loading causes abandonment. Use <script src="_framework/blazor.webassembly.js" autostart="false"></script> with custom loading UI, enable Brotli compression, and tree-shake unused packages via <BlazorWebAssemblyEnableLinking>true</BlazorWebAssemblyEnableLinking>.
5. Mixing Routing Paradigms Without Boundaries
Running MVC and Blazor in the same project without route partitioning causes EndpointRouting conflicts. MVC uses MapControllerRoute, Blazor uses MapRazorComponents. In .NET 8+, use AddRazorComponents() alongside AddControllersWithViews(), but explicitly define route prefixes. Example: endpoints.MapControllers(); then endpoints.MapRazorComponents<App>().AddInteractiveServerRenderMode(); with distinct base paths.
6. Static State Management Anti-Patterns
Using static fields or singletons for UI state in Blazor breaks concurrency and causes cross-user data leakage. Rely on DI-scoped services (AddScoped for Server, AddTransient for WASM) or PersistentComponentState. For complex state, implement a lightweight Redux-style store or use ComponentBase event aggregation.
7. Neglecting SignalR Connection Health
Blazor Server depends on WebSocket/Long Polling. Network interruptions silently break components if reconnection isn't handled. Implement Router with Found and NotFound templates, add a reconnection UI overlay, and configure HubOptions for appropriate keep-alive intervals. Monitor CircuitHandler for disconnect events to clean up resources.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public marketing site with SEO requirements | MVC | Server-rendered HTML ensures crawler compatibility and fast initial paint | Low infrastructure cost, high CDN efficiency |
| Real-time dashboard with live metrics | Blazor Server | SignalR provides sub-50ms updates without polling overhead | Moderate server memory cost, reduced client bandwidth |
| Offline-capable field application | Blazor WASM/Interactive | Runs entirely in browser; resilient to network drops | Higher initial payload, lower long-term server costs |
| Internal CRUD admin panel with moderate interactivity | Blazor Interactive (Auto) | Balances SSR speed with client-side interactivity | Balanced compute distribution, predictable scaling |
| High-concurrency public API + lightweight UI | MVC + minimal JS | Stateless architecture scales horizontally with ease | Lowest server resource consumption per request |
Configuration Template
// Program.cs - .NET 8 Hybrid Setup
var builder = WebApplication.CreateBuilder(args);
// Shared services
builder.Services.AddRazorPages();
builder.Services.AddControllersWithViews();
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
// Domain & Data
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.AddScoped<IInventoryService, InventoryService>();
builder.Services.AddHttpClient();
// SignalR & Circuit tuning
builder.Services.AddSignalR(hub =>
{
hub.MaximumReceiveMessageSize = 1024 * 1024; // 1MB
hub.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
hub.KeepAliveInterval = TimeSpan.FromSeconds(15);
});
builder.Services.Configure<CircuitOptions>(options =>
{
options.DisconnectedCircuitMaxRetained = 1000;
options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(3);
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
// Explicit routing boundaries
app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Inventory).Assembly);
app.Run();
Quick Start Guide
- Scaffold the solution: Run
dotnet new webapp -n BlazorMvcComparison and add a Blazor component project via dotnet new blazor -n SharedUI. Reference SharedUI from the main project.
- Configure routing boundaries: In
Program.cs, add MapControllers() for MVC routes and MapRazorComponents<App>() for Blazor. Set MVC base path to /admin and Blazor to /app using [Route("admin/[controller]")] and @page "/app/inventory".
- Implement shared contracts: Create a
Contracts class library with ProductDto and InventoryItem. Reference it in both UI projects. Implement a minimal IProductService with in-memory fallback for immediate testing.
- Add connection resilience: In the Blazor layout, insert
<Routes> with a custom Found template that includes a reconnection overlay. Configure CircuitOptions in Program.cs as shown in the template.
- Run and validate: Execute
dotnet run. Navigate to /admin/products for MVC rendering and /app/inventory for Blazor. Verify TTI using browser DevTools Network tab and confirm SignalR circuit establishment via ws://localhost:5000/_blazor.