Post-migration async/await and .NET 10 performance optimization pass for Blazor apps migrated from Web Forms. Applies modern runtime patterns after the app builds and runs. WHEN: "run L3 optimization", "apply async/await fixes", "optimize migrated Blazor app", "AsNoTracking queries", "StreamRendering", "IDbContextFactory pattern", "what .NET 10 optimizations can we apply", "generate L3 report".
88
88%
Does it follow best practices?
Impact
84%
1.15xAverage score across 3 eval scenarios
Passed
No known issues
This skill is the optional fourth step in the migration pipeline. It applies after the app builds and runs correctly following L1 (automated), L2 (structural), and L3-architecture (data/identity) passes.
Related skills:
/bwfc-migration — Core markup migration (controls, expressions, layouts)/bwfc-data-migration — EF6 → EF Core, architecture decisions/bwfc-identity-migration — Authentication migrationApply L3 optimizations when:
Do NOT run L3 before the app is functional. L3 assumes L1 + L2 + L3-architecture are complete. Applying async patterns to broken code makes debugging harder.
Each optimization in this skill is rated:
| Rating | Meaning |
|---|---|
| ✅ Safe | Drop-in change, identical behavior, no review needed |
| ⚠️ Review | Almost always correct, but verify behavior in your specific context |
| 🔴 Risky | Possible semantic difference — review before committing |
# Apply all safe optimizations to the whole project
"Run L3 optimization on the migrated ContosoUniversity app"
# Apply a single category
"Apply async/await fixes to Students.razor.cs"
# Report only — no changes
"Generate an L3 optimization report for AfterWingtipToys"
# Ask what's applicable
"What .NET 10 optimizations can we apply to this file?"The most impactful change in the L3 pass. Synchronous EF Core calls block the thread pool, hurting throughput under load. Web Forms code-behind often used synchronous DB calls; migrated code preserves that pattern by default.
OnInitialized → OnInitializedAsyncApplies when OnInitialized contains database queries or other I/O.
Before (AfterContosoUniversity pattern):
protected override void OnInitialized()
{
_students = studLogic.GetJoinedTableData();
_courseNames = studLogic.GetCourseNames();
}After:
protected override async Task OnInitializedAsync()
{
_students = await studLogic.GetJoinedTableDataAsync();
_courseNames = await studLogic.GetCourseNamesAsync();
}Why:
OnInitializedAsyncis awaited by Blazor's rendering pipeline. Synchronous blocking insideOnInitializedties up the thread for the duration of the database call, whileawaityields the thread back to the pool during I/O.
Replace synchronous EF Core terminal operators with their async counterparts.
| Before | After | Rating |
|---|---|---|
db.Products.ToList() | await db.Products.ToListAsync() | ✅ Safe |
db.Products.FirstOrDefault(...) | await db.Products.FirstOrDefaultAsync(...) | ✅ Safe |
db.Products.SingleOrDefault(...) | await db.Products.SingleOrDefaultAsync(...) | ✅ Safe |
db.Products.Find(id) | await db.Products.FindAsync(id) | ✅ Safe |
db.SaveChanges() | await db.SaveChangesAsync() | ✅ Safe |
db.Products.Count() | await db.Products.CountAsync() | ✅ Safe |
db.Products.Any(...) | await db.Products.AnyAsync(...) | ✅ Safe |
Before:
private void LoadProducts()
{
using var db = DbFactory.CreateDbContext();
_products = db.Products.Where(p => p.CategoryID == _catId).ToList();
}After:
private async Task LoadProductsAsync()
{
using var db = DbFactory.CreateDbContext();
_products = await db.Products.Where(p => p.CategoryID == _catId).ToListAsync();
}Call sites must also become
async Taskand useawait. Update event handlers and lifecycle methods accordingly.
Task.Result / Task.Wait() Anti-Patterns → awaitThese patterns deadlock under Blazor's synchronization context.
Before:
var result = SomeAsyncMethod().Result; // ❌ deadlock risk
SomeAsyncMethod().Wait(); // ❌ deadlock riskAfter:
var result = await SomeAsyncMethod(); // ✅
await SomeAsyncMethod(); // ✅Rating: ✅ Safe —
awaitis semantically identical for Blazor components where there is noSynchronizationContextdeadlock concern, but removing.Result/.Wait()is always correct.
async TaskBefore:
private void btnInsert_Click()
{
studLogic.InsertNewEntry(_firstName, _lastName, birth, _selectedCourse, _email);
_students = studLogic.GetJoinedTableData();
}After:
private async Task btnInsert_Click()
{
await studLogic.InsertNewEntryAsync(_firstName, _lastName, birth, _selectedCourse, _email);
_students = await studLogic.GetJoinedTableDataAsync();
}Note: Blazor's
EventCallbacksupportsasync Taskhandlers natively. The framework awaits them and triggers re-render automatically.
AsNoTracking() to Read-Only Queries ✅ SafeEF Core tracks every entity it loads by default. For read-only list pages and reports, tracking adds CPU and memory overhead with no benefit.
Before:
using var db = DbFactory.CreateDbContext();
_products = await db.Products.ToListAsync();After:
using var db = DbFactory.CreateDbContext();
_products = await db.Products.AsNoTracking().ToListAsync();When to apply:
db.SaveChanges() on the loaded entitiesOnInitializedAsync data loads displayed in grids or detail viewsAsNoTracking() when you load an entity and then modify + save it in the same DbContext instanceInclude() with Lambda Include() ✅ SafeEF6 used string-based includes. EF Core supports both, but lambda includes are refactor-safe and get compile-time checking.
Before (EF6 migration artifact):
db.Products.Include("Category").ToList()After:
await db.Products.Include(p => p.Category).ToListAsync()AsSplitQuery() for Multi-Collection Includes ⚠️ ReviewWhen loading an entity with multiple collection navigations, EF Core generates a cartesian JOIN that multiplies result rows. Split queries issue separate SQL statements instead.
Before:
var instructors = await db.Instructors
.Include(i => i.Courses)
.Include(i => i.OfficeAssignment)
.ToListAsync();After:
var instructors = await db.Instructors
.Include(i => i.Courses)
.Include(i => i.OfficeAssignment)
.AsSplitQuery()
.ToListAsync();⚠️ Review: Split queries use multiple round-trips. They are faster when the cartesian product is large, but slower if network latency dominates. Measure before committing. See EF Core split queries docs.
N+1 occurs when code lazily loads a navigation property inside a loop.
Before (N+1):
// Loads N students, then issues 1 additional query per student to load Enrollments
foreach (var student in _students)
{
var count = student.Enrollments.Count; // lazy load per student
}After:
// Single query with eager loading
_students = await db.Students
.Include(s => s.Enrollments)
.AsNoTracking()
.ToListAsync();How to find N+1: Enable EF Core logging (
optionsBuilder.LogTo(Console.WriteLine)) during development and look for repeated identical queries with only the ID parameter changing.
[SupplyParameterFromQuery] for Route/Query Parameters ✅ SafeReplaces manual NavigationManager.Uri parsing for query string values.
Before:
[Inject] private NavigationManager Navigation { get; set; } = default!;
protected override void OnInitialized()
{
var uri = new Uri(Navigation.Uri);
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
_productAction = query["ProductAction"];
}After:
[SupplyParameterFromQuery(Name = "ProductAction")]
public string? ProductAction { get; set; }Note:
[SupplyParameterFromQuery]only works on routable components (those with@page). For non-routable child components, continue using[Parameter]passed from the parent.
[StreamRendering] for Pages with Async Data Loading ⚠️ Review[StreamRendering] lets Blazor send the initial HTML to the browser immediately, then stream in the loaded content. This reduces time-to-first-byte for data-heavy pages.
Before:
@page "/Students"
@code {
protected override async Task OnInitializedAsync()
{
_students = await studLogic.GetJoinedTableDataAsync();
}
}After:
@page "/Students"
@attribute [StreamRendering]
@code {
protected override async Task OnInitializedAsync()
{
_students = await studLogic.GetJoinedTableDataAsync();
}
}Add a loading placeholder in the markup:
@if (_students is null)
{
<p>Loading...</p>
}
else
{
<GridView Items="@_students" ... />
}⚠️ Review:
[StreamRendering]requires the component to be in static SSR mode or to handle the null/loading state correctly. It works best withInteractiveServerglobal mode when the page has a clear loading placeholder. Verify that your layout does not depend on data being ready during the initial render.
@rendermode InteractiveServer to Pages That Need It ⚠️ ReviewThe current migration standard sets InteractiveServer globally in App.razor. This is correct for apps heavy on interactivity. For apps where most pages are read-only displays, you can reduce server resource usage by using static SSR for read-only pages.
Global (current standard — correct for most migrated apps):
@* App.razor *@
<Routes @rendermode="InteractiveServer" />Per-page opt-in (only for apps where most pages are non-interactive):
@* InteractivePages/Edit.razor *@
@rendermode InteractiveServer⚠️ Review: Changing from global interactive to per-page requires auditing every page for event handlers and two-way bindings. Pages without
@rendermodebecome static SSR and will not process Blazor events. Only consider this if you have a clear majority of truly static display pages. Most migrated Web Forms apps have enough interactivity that globalInteractiveServeris the right choice.
Before:
var message = "Student " + firstName + " " + lastName + " enrolled on " + date.ToString("d");After:
var message = $"Student {firstName} {lastName} enrolled on {date:d}";@key on @foreach Loops Rendering Components ✅ SafeWithout @key, Blazor diffs lists by position. When items are inserted, removed, or reordered, components at those positions are unnecessarily destroyed and recreated. Adding @key lets Blazor track items by identity.
Before:
@foreach (var product in _products)
{
<ProductCard Product="@product" />
}After:
@foreach (var product in _products)
{
<ProductCard @key="product.ProductID" Product="@product" />
}Use the entity's primary key as the
@keyvalue. Avoid using loop index (i) as@key— it defeats the purpose because the index is positional, not identity-based.
ShouldRender() for Components with Frequent Parent Re-Renders ⚠️ ReviewComponents that receive the same data repeatedly (e.g., a static header, a read-only summary panel) still re-render on every parent state change by default.
Before:
// Component re-renders on every parent state change even when _summary hasn't changedAfter:
private CourseSummary? _previousSummary;
protected override bool ShouldRender()
{
if (_summary == _previousSummary) return false;
_previousSummary = _summary;
return true;
}⚠️ Review:
ShouldRender()overrides can suppress necessary re-renders if state comparison logic is incomplete. Only apply to leaf components with stable, comparable inputs. Do not apply to components that receiveRenderFragmentparameters — those cannot be compared for equality.
@code Blocks to Code-Behind ✅ SafeInline @code blocks longer than ~50 lines hurt maintainability and slow down IDE tooling. Move them to partial class code-behind files.
Before:
@* BigPage.razor *@
@page "/BigPage"
@code {
// 150+ lines of logic
[Inject] private IDbContextFactory<AppContext> DbFactory { get; set; } = default!;
private List<Order> _orders = new();
// ... more fields ...
protected override async Task OnInitializedAsync() { ... }
private async Task SaveOrder() { ... }
// ... more methods ...
}After:
@* BigPage.razor *@
@page "/BigPage"
@* No @code block — logic lives in BigPage.razor.cs *@// BigPage.razor.cs
namespace MyApp.Pages;
public partial class BigPage
{
[Inject] private IDbContextFactory<AppContext> DbFactory { get; set; } = default!;
private List<Order> _orders = new();
// ...
protected override async Task OnInitializedAsync() { ... }
private async Task SaveOrder() { ... }
}[EditorRequired] on Mandatory Parameters ✅ SafePrevents accidental omission of required parameters at the call site (build warning, not error).
Before:
[Parameter]
public Product Product { get; set; } = default!;After:
[Parameter, EditorRequired]
public Product Product { get; set; } = default!;@inject DbContext → IDbContextFactory<T> ✅ SafeDirect DbContext injection (AddDbContext) uses a scoped lifetime that matches a Blazor circuit, not a request. Long-lived circuits accumulate tracked entities and can serve stale data. IDbContextFactory creates short-lived contexts per operation.
Before:
// Program.cs
builder.Services.AddDbContext<ProductContext>(options =>
options.UseSqlServer(connectionString));
// Component
[Inject] private ProductContext Db { get; set; } = default!;
private async Task LoadProducts()
{
_products = await Db.Products.ToListAsync();
}After:
// Program.cs
builder.Services.AddDbContextFactory<ProductContext>(options =>
options.UseSqlServer(connectionString));
// Component
[Inject] private IDbContextFactory<ProductContext> DbFactory { get; set; } = default!;
private async Task LoadProducts()
{
using var db = DbFactory.CreateDbContext();
_products = await db.Products.AsNoTracking().ToListAsync();
}The
AfterWingtipToyssample already uses this pattern correctly. Apply it to any remainingAddDbContextregistrations in migrated apps.
[Inject] Attribute vs @inject Directive ✅ SafeBoth work, but mixing styles in the same component is inconsistent. The recommended style for code-behind files is [Inject] attribute.
| Location | Recommended |
|---|---|
.razor file (no code-behind) | @inject IService Service |
.razor.cs code-behind file | [Inject] private IService Service { get; set; } = default!; |
Before (mixed styles in code-behind):
// AdminPage.razor.cs
[Inject] private IDbContextFactory<ProductContext> DbFactory { get; set; } = default!;
// AdminPage.razor (inline)
@inject NavigationManager NavigationManagerAfter (all injections in code-behind):
// AdminPage.razor.cs
[Inject] private IDbContextFactory<ProductContext> DbFactory { get; set; } = default!;
[Inject] private NavigationManager NavigationManager { get; set; } = default!;Components that create disposable resources (opened DB connections, HTTP clients, streams) must implement IAsyncDisposable or IDisposable.
Before:
public partial class ReportPage
{
private AppDbContext? _db;
protected override async Task OnInitializedAsync()
{
_db = DbFactory.CreateDbContext();
_report = await _db.Reports.ToListAsync();
}
// ❌ _db never disposed — connection leak
}After (preferred — per-operation context, no field):
protected override async Task OnInitializedAsync()
{
using var db = DbFactory.CreateDbContext();
_report = await db.Reports.AsNoTracking().ToListAsync();
// db disposed here — no field needed
}After (if context must live across methods — implement disposal):
public partial class ReportPage : IAsyncDisposable
{
private AppDbContext? _db;
protected override async Task OnInitializedAsync()
{
_db = DbFactory.CreateDbContext();
_report = await _db.Reports.AsNoTracking().ToListAsync();
}
public async ValueTask DisposeAsync()
{
if (_db is not null)
await _db.DisposeAsync();
}
}Apply optimizations in this order to minimize risk:
IDbContextFactory pattern (§5a) — foundational; enables safe async DB operationsOnInitialized → OnInitializedAsync (§1a) + EF Core sync → async (§1b) — biggest throughput gainAsNoTracking() on read-only queries (§2a) — safe, measurable memory reduction@key on foreach loops (§4a) — DOM diffing improvement[SupplyParameterFromQuery] (§3a) — cleanup / remove boilerplate[StreamRendering] (§3b) — advanced; only after above are doneShouldRender() (§4b) — only for specific high-frequency-render componentsWhen generating a report, use this format:
## L3 Optimization Report — [ProjectName]
**Date:** [date]
**Files reviewed:** [N]
**Files changed:** [N]
### Applied Changes
| File | Optimization | Category | Confidence |
|------|-------------|---------|----------|
| Students.razor.cs | OnInitialized → OnInitializedAsync | Async/Await | ✅ Safe |
| Students.razor.cs | GetJoinedTableData → async + ToListAsync | Async/Await | ✅ Safe |
| Courses.razor | @key added to foreach | Component | ✅ Safe |
### Skipped / Needs Review
| File | Optimization | Reason |
|------|-------------|--------|
| Students.razor | [StreamRendering] | Loading state placeholder not present |
| Instructors.razor.cs | ShouldRender() | Complex state comparison needed |
### Before/After Summary
**Students.razor.cs — OnInitialized sync → async**
Before:
```csharp
protected override void OnInitialized()
{
_students = studLogic.GetJoinedTableData();
}After:
protected override async Task OnInitializedAsync()
{
_students = await studLogic.GetJoinedTableDataAsync();
}---
## Anti-Patterns
### ❌ Applying L3 to a Broken Build"Apply L3 optimizations" [while there are compile errors]
Async patterns surface errors that were previously hidden. Fix all build errors first.
### ❌ Adding `AsNoTracking()` to Write Operations
```csharp
// WRONG — loading with AsNoTracking then trying to save
using var db = DbFactory.CreateDbContext();
var product = await db.Products.AsNoTracking().FirstAsync(p => p.ID == id);
product.Price = newPrice;
await db.SaveChangesAsync(); // ❌ throws — entity not tracked// RIGHT — no AsNoTracking when you intend to modify and save
using var db = DbFactory.CreateDbContext();
var product = await db.Products.FirstAsync(p => p.ID == id);
product.Price = newPrice;
await db.SaveChangesAsync(); // ✅@key@* WRONG — positional key defeats diffing optimization *@
@for (int i = 0; i < _products.Count; i++)
{
<ProductCard @key="i" Product="@_products[i]" />
}
@* RIGHT — identity-based key *@
@foreach (var product in _products)
{
<ProductCard @key="product.ProductID" Product="@product" />
}// If you add async to the interface...
public interface IStudentsLogic
{
Task<List<object>> GetJoinedTableDataAsync();
}
// ...you MUST update the implementation too
public class StudentsListLogic : IStudentsLogic
{
public async Task<List<object>> GetJoinedTableDataAsync()
{
using var db = _factory.CreateDbContext();
return await db.Students.AsNoTracking().Select(...).ToListAsync();
}
}9bf8669
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.