Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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".
.claude/skills/fritzandfriends-l3-performance-optimization/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-16 | ✗→✓ | ▲ Improved | 205% | 0% |
| case-10 | ✓→✗ | ▼ Worse | 251% | 0% |
| case-02 | ✓→✓ | = Same ✓ | 169% | 0% |
| case-03 | ✓→✓ | = Same ✓ | 221% | 0% |
| case-04 | ✓→✓ | = Same ✓ | 214% | 0% |
> ⚠️ WARNING: L3 optimization runs AFTER the app builds and works correctly. Do NOT apply L3 changes during initial migration. Use shims first (Session, Response, Request) for functional correctness, then optimize later if needed.
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):
csharpprotected override void OnInitialized() { _students = studLogic.GetJoinedTableData(); _courseNames = studLogic.GetCourseNames(); }
After:
csharpprotected override async Task OnInitializedAsync() { _students = await studLogic.GetJoinedTableDataAsync(); _courseNames = await studLogic.GetCourseNamesAsync(); }
> Why: OnInitializedAsync is awaited by Blazor's rendering pipeline. Synchronous blocking inside OnInitialized ties up the thread for the duration of the database call, while await yields 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:
csharpprivate void LoadProducts() { using var db = DbFactory.CreateDbContext(); _products = db.Products.Where(p => p.CategoryID == _catId).ToList(); }
After:
csharpprivate async Task LoadProductsAsync() { using var db = DbFactory.CreateDbContext(); _products = await db.Products.Where(p => p.CategoryID == _catId).ToListAsync(); }
> Call sites must also become async Task and use await. Update event handlers and lifecycle methods accordingly.
Task.Result / Task.Wait() Anti-Patterns → awaitThese patterns deadlock under Blazor's synchronization context.
Before:
csharpvar result = SomeAsyncMethod().Result; // ❌ deadlock risk SomeAsyncMethod().Wait(); // ❌ deadlock risk
After:
csharpvar result = await SomeAsyncMethod(); // ✅ await SomeAsyncMethod(); // ✅
> Rating: ✅ Safe — await is semantically identical for Blazor components where there is no SynchronizationContext deadlock concern, but removing .Result/.Wait() is always correct.
async TaskBefore:
csharpprivate void btnInsert_Click() { studLogic.InsertNewEntry(_firstName, _lastName, birth, _selectedCourse, _email); _students = studLogic.GetJoinedTableData(); }
After:
csharpprivate async Task btnInsert_Click() { await studLogic.InsertNewEntryAsync(_firstName, _lastName, birth, _selectedCourse, _email); _students = await studLogic.GetJoinedTableDataAsync(); }
> Note: Blazor's EventCallback supports async Task handlers 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:
csharpusing var db = DbFactory.CreateDbContext(); _products = await db.Products.ToListAsync();
After:
csharpusing 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):
csharpdb.Products.Include("Category").ToList()
After:
csharpawait 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:
csharpvar instructors = await db.Instructors .Include(i => i.Courses) .Include(i => i.OfficeAssignment) .ToListAsync();
After:
csharpvar 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):
csharp// 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:
csharp// 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:
csharp[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:
csharp[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:
razor@page "/Students" @code { protected override async Task OnInitializedAsync() { _students = await studLogic.GetJoinedTableDataAsync(); } }
After:
razor@page "/Students" @attribute [StreamRendering] @code { protected override async Task OnInitializedAsync() { _students = await studLogic.GetJoinedTableDataAsync(); } }
Add a loading placeholder in the markup:
razor@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 with InteractiveServer global 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):
razor@* App.razor *@ <Routes @rendermode="InteractiveServer" />
Per-page opt-in (only for apps where most pages are non-interactive):
razor@* 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 @rendermode become 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 global InteractiveServer is the right choice.
Before:
csharpvar message = "Student " + firstName + " " + lastName + " enrolled on " + date.ToString("d");
After:
csharpvar 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:
razor@foreach (var product in _products) { <ProductCard Product="@product" /> }
After:
razor@foreach (var product in _products) { <ProductCard @key="product.ProductID" Product="@product" /> }
> Use the entity's primary key as the @key value. 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:
csharp// Component re-renders on every parent state change even when _summary hasn't changed
After:
csharpprivate 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 receive RenderFragment parameters — 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:
razor@* 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:
razor@* BigPage.razor *@ @page "/BigPage" @* No @code block — logic lives in BigPage.razor.cs *@
csharp// 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:
csharp[Parameter] public Product Product { get; set; } = default!;
After:
csharp[Parameter, EditorRequired] public Product Product { get; set; } = default!;
> Note: During initial migration (L1/L2), keep shim usage (Session, Response, Request from WebFormsPageBase). These work correctly and provide the same patterns as Web Forms. L3 optimization may replace shims with native Blazor patterns (NavigationManager, scoped services, IMemoryCache), but ONLY after the app is verified working. Premature shim removal during L1/L2 migration breaks functionality and makes debugging harder.
@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:
csharp// 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:
csharp// 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 AfterWingtipToys sample already uses this pattern correctly. Apply it to any remaining AddDbContext registrations 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):
csharp// AdminPage.razor.cs [Inject] private IDbContextFactory<ProductContext> DbFactory { get; set; } = default!; // AdminPage.razor (inline) @inject NavigationManager NavigationManager
After (all injections in code-behind):
csharp// 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:
csharppublic 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):
csharpprotected 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):
csharppublic 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:
markdown## 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:
protected override void OnInitialized() { _students = studLogic.GetJoinedTableData(); }
After:protected override async Task OnInitializedAsync() { _students = await studLogic.GetJoinedTableDataAsync(); }
# WRONG — run only after the app builds and runs
"Apply L3 optimizations" [while there are compile errors]Async patterns surface errors that were previously hidden. Fix all build errors first.
csharp// WRONG — removing shims before app builds and works Session["key"] → inject scoped service (during L1/L2) Response.Redirect() → NavigationManager.NavigateTo() (during L1/L2) Request.QueryString[] → [SupplyParameterFromQuery] (during L1/L2) // RIGHT — use shims first, optimize later Layer 1/2: Use Session["key"], Response.Redirect(), Request.QueryString[] (shims work correctly) Layer 3 (optional): Convert to native patterns ONLY after app is verified working
Shim-based code is functionally correct. Replacing shims prematurely breaks the build and makes debugging harder.
AsNoTracking() to Write Operationscsharp// 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
csharp// 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(); // ✅
@keyrazor@* 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" /> }
csharp// 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(); } }
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 18,917 | 20,674 | +9% | 1 | 1 | 0% | 3,189 | 10,014 | +214% | 0 | 0 | — |
case-02 | pass→pass | 19,075 | 18,781 | -2% | 1 | 1 | 0% | 3,702 | 9,963 | +169% | 0 | 0 | — |
case-03 | pass→pass | 14,713 | 6,687 | -55% | 1 | 1 | 0% | 2,243 | 7,189 | +221% | 0 | 0 | — |
case-04 | pass→pass | 14,633 | 6,152 | -58% | 1 | 1 | 0% | 2,268 | 7,128 | +214% | 0 | 0 | — |
case-05 | pass→pass | 13,668 | 8,024 | -41% | 1 | 1 | 0% | 2,185 | 7,299 | +234% | 0 | 0 | — |
case-06 | pass→pass | 11,950 | 5,584 | -53% | 1 | 1 | 0% | 2,079 | 7,017 | +238% | 0 | 0 | — |
case-07 | pass→pass | 8,676 | 4,168 | -52% | 1 | 1 | 0% | 1,640 | 6,659 | +306% | 0 | 0 | — |
case-08 | pass→pass | 6,364 | 3,869 | -39% | 1 | 1 | 0% | 1,001 | 6,642 | +564% | 0 | 0 | — |
case-09 | pass→pass | 8,869 | 4,992 | -44% | 1 | 1 | 0% | 1,659 | 6,905 | +316% | 0 | 0 | — |
case-10 | pass→fail | 12,216 | 3,062 | -75% | 1 | 1 | 0% | 1,838 | 6,455 | +251% | 0 | 0 | — |
case-11 | pass→pass | 10,610 | 8,679 | -18% | 1 | 1 | 0% | 1,795 | 7,439 | +314% | 0 | 0 | — |
case-12 | pass→pass | 3,354 | 3,295 | -2% | 1 | 1 | 0% | 593 | 6,557 | +1006% | 0 | 0 | — |
case-13 | pass→pass | 5,694 | 3,854 | -32% | 1 | 1 | 0% | 1,023 | 6,672 | +552% | 0 | 0 | — |
case-14 | pass→pass | 10,526 | 4,942 | -53% | 1 | 1 | 0% | 1,721 | 6,802 | +295% | 0 | 0 | — |
case-19 | pass→pass | 6,832 | 5,953 | -13% | 1 | 1 | 0% | 1,265 | 7,081 | +460% | 0 | 0 | — |
case-15 | pass→pass | 7,300 | 3,742 | -49% | 1 | 1 | 0% | 1,331 | 6,621 | +397% | 0 | 0 | — |
case-16 | fail→pass | 12,032 | 4,646 | -61% | 1 | 1 | 0% | 2,210 | 6,750 | +205% | 0 | 0 | — |
case-17 | pass→pass | 12,140 | 6,902 | -43% | 1 | 1 | 0% | 2,053 | 7,263 | +254% | 0 | 0 | — |
case-18 | pass→pass | 3,510 | 3,753 | +7% | 1 | 1 | 0% | 528 | 6,610 | +1152% | 0 | 0 | — |
case-20 | pass→pass | 9,578 | 3,845 | -60% | 1 | 1 | 0% | 1,673 | 6,679 | +299% | 0 | 0 | — |
case-21 | pass→pass | 11,398 | 6,404 | -44% | 1 | 1 | 0% | 1,938 | 7,208 | +272% | 0 | 0 | — |
case-22 | pass→pass | 11,566 | 6,316 | -45% | 1 | 1 | 0% | 1,760 | 7,148 | +306% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of 0 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.