Analyzer rule
LC008: Sync-over-Async Blocker
LC008 flags synchronous EF Core calls such as ToList or SaveChanges inside async methods, where they block threads. Use the Async counterpart.
- Default severity
- Warning
- Code fix
- Available
- Category
- Performance
- Domain
- Execution & Async
Change the severity in .editorconfig:
[*.cs]
dotnet_diagnostic.LC008.severity = error
LC008: Sync-over-Async Blocker
In Plain Terms
Imagine a waiter taking your order, then walking into the kitchen and staring at the chef for 20 minutes until the food is ready. No one else gets served. That’s Sync-over-Async. Async means the waiter takes the order and goes to serve other tables while the food cooks.
Goal
Detect synchronous EF Core query and save operations inside an async context when an async counterpart exists.
The Problem
Synchronous calls like db.Users.ToList() block the current thread while waiting for the database response. In a web server, this leads to “Thread Starvation,” where all available threads are stuck waiting, preventing the server from handling other incoming requests.
LC008 focuses on database I/O that happens directly inside async control flow. It reports mapped synchronous methods when they execute against EF-backed sources:
- query materializers:
ToList,ToArray,ToDictionary, andToHashSet - query terminals:
First,FirstOrDefault,Single,SingleOrDefault,Last,LastOrDefault,Count,LongCount,Any,All,Min,Max,Sum, andAverage - context/set operations:
SaveChanges,Find,ExecuteUpdate, andExecuteDelete
Example Violation
public async Task<List<User>> GetUsersAsync()
{
// Violation: Blocks the thread. Should use ToListAsync().
return db.Users.ToList();
}
public async Task<int> CountUsersAsync()
{
// Violation: Count() blocks the async method.
return db.Users.Count();
}
public async Task SaveAsync()
{
UpdateEntities();
// Violation: synchronous database save inside async flow.
db.SaveChanges();
}
The Fix
Use the asynchronous counterpart and await it.
public async Task<List<User>> GetUsersAsync()
{
// Correct: Frees up the thread while waiting
return await db.Users.ToListAsync();
}
public async Task<int> CountUsersAsync()
{
return await db.Users.CountAsync();
}
public async Task SaveAsync()
{
UpdateEntities();
await db.SaveChangesAsync();
}
LC062 covers the reverse: blocking on an EF Core async call with .Result, .Wait() or .GetAwaiter().GetResult(), in any method. See LC062: blocking on an EF Core async call.
Async Counterpart Families
LC008 uses a fixed sync-to-async map. If a method has no mapped async counterpart, LC008 does not guess at one and does not report it as a sync-over-async violation.
| Sync call | Suggested async call |
|---|---|
ToList, ToArray, ToDictionary, ToHashSet |
ToListAsync, ToArrayAsync, ToDictionaryAsync, ToHashSetAsync |
First, FirstOrDefault, Single, SingleOrDefault, Last, LastOrDefault |
matching *Async terminal |
Count, LongCount, Any, All, Min, Max, Sum, Average |
matching *Async aggregate |
SaveChanges |
SaveChangesAsync |
Find |
FindAsync |
ExecuteUpdate, ExecuteDelete |
ExecuteUpdateAsync, ExecuteDeleteAsync |
When an EF API truly has no async equivalent, the rule’s advice is not to wrap it in Task.Run. Keep that work outside the hot async request path, move the surrounding workflow to a synchronous boundary, or document the deliberate blocking call with a suppression.
Async Context Boundaries
LC008 reports inside:
async Task,async ValueTask, and other async methods- async lambdas
- non-async lambdas and capturing local functions nested inside an async method
LC008 intentionally ignores sync-looking LINQ operators inside IQueryable expression trees, such as query-expression subqueries in let, where, or projection clauses. Those calls are translated as SQL subqueries instead of blocking the async method directly.
public async Task QueryAsync()
{
var query =
from user in db.Users
let matches = db.Users.Where(inner => inner.Id == user.Id).ToList()
select new { user, matches };
await Task.Delay(1);
}
The async-context walk treats a static local function as a hard synchronous boundary: it cannot capture the enclosing async context, await is illegal inside it, and a diagnostic there would be unfixable, so a sync EF call inside a static local function declared in an async method stays quiet. A capturing non-static local function or lambda inside an async method still flags because it is refactoring debt the rule deliberately surfaces, and an async static local function flags too because it can await the async counterpart.
public async Task LoadAsync()
{
var users = Load(db);
await Task.Delay(1);
static List<User> Load(AppDbContext db)
{
return db.Users.ToList(); // no LC008: static local synchronous boundary
}
}
In-Memory Queries
A query built over an in-memory collection with AsQueryable() runs on LINQ to Objects. A sync Count() or ToList() on it does no I/O, and the async rewrite would throw at run time (“The source IQueryable doesn’t implement IAsyncEnumerable”), so LC008 stays quiet when it can prove the query is in memory:
- the chain starts at
AsQueryable()over an array or a concrete collection class such asList<T>orHashSet<T>, over a value typed asICollection<T>,IList<T>,IReadOnlyCollection<T>,IReadOnlyList<T>,ISet<T>orIReadOnlySet<T>(no EF query type implements them, so Cofoundry’sdbVersion.CustomEntityVersionPageBlocks.AsQueryable()over an entity navigation is in memory), over the result of aSystem.Linq.Enumerableoperator such asSelectwhose every input sequence, and every sequence aSelectManycollection selector returns, is itself in memory (operators are lazy, sodb.Users.AsEnumerable().Where(...).AsQueryable()still enumerates the EF query and still reports), or atnew EnumerableQuery<T>(...), and only adds library query operators (Where,Skip,Take, …); - a local is followed through every write in its method, including writes inside lambdas and local functions: each write must be in-memory-rooted or composed from the same local (
query = query.Where(...)); - a call to a non-virtual helper declared in the same compilation is followed when every
returnin its body is composed only from oneIQueryable<T>parameter (reassignments of that parameter included), and LC008 then checks the argument passed for it; - a helper whose body LC008 cannot see (a referenced project or package) is not trusted, even when it is handed only an in-memory query: it can return static or injected query state, so SimpleIdServer’s
scimFilter.EvaluateAttributes(BuildHierarchicalAttributes(list).AsQueryable(), false).ToList()still reports.
public async Task<(int, List<Issue>)> GetIssuesAsync(bool onlyErrors, int page, int size)
{
var allIssues = await LoadAllIssuesAsync(); // List<Issue>
var filtered = allIssues.AsQueryable();
if (onlyErrors) filtered = filtered.Where(i => i.IsError);
var total = filtered.Count(); // no LC008: LINQ to Objects
var items = Filter(filtered.Skip(page * size).Take(size)).ToList(); // no LC008
return (total, items);
}
private IQueryable<Issue> Filter(IQueryable<Issue> query) => query.OrderBy(i => i.Id);
LC008 still reports when any write to the local, or the helper argument, could be an EF query (db.Set<T>(), a DbSet property, a repository Query() that returns one), when the local is written through ref/out, ??= or deconstruction, when the helper is virtual or abstract, when a helper declared in another project is an instance method or is handed anything that could reach EF, and when AsQueryable() wraps an interface-typed sequence such as IEnumerable<T> whose element is an entity (some DbContext in the project or its references exposes a DbSet<T> of it), because it may be a DbSet at run time. An interface-typed sequence of a non-entity type, such as VirtoCommerce’s IEnumerable<SettingDescriptor> AllRegisteredSettings, is in memory and stays quiet. Because these in-memory shapes do not report, the fixer is never offered for them.
Fixer Behavior
The fixer is intentionally narrow. It replaces the method name with the mapped async name and wraps the invocation in await only when await is legal at that syntax location.
db.Users.ToList()becomesawait db.Users.ToListAsync().db.SaveChanges()becomesawait db.SaveChangesAsync().- When the sync call feeds a following member, element, invocation, or null-conditional access, the fixer parenthesizes the awaited result:
db.Users.ToList().Countbecomes(await db.Users.ToListAsync()).Count, anddb.Users.FirstOrDefault()?.Namebecomes(await db.Users.FirstOrDefaultAsync())?.Name. - Query-expression subqueries that are part of the provider expression do not report and therefore do not offer a fix.
- Diagnostics inside non-async lambdas or non-async local functions can report, but the fixer stays quiet because inserting
awaitthere would not compile without refactoring the delegate/local-function shape. - Calls inside a
lockbody, anunsafeblock, or anunsafemethod, local function or type still report, but the fixer stays quiet: C# forbidsawaitthere (CS1996 and CS4004). Move the query out of the lock or unsafe region first, then apply the fix. - The fixer does not add cancellation tokens or choose overloads; pass tokens explicitly after the rewrite when the surrounding code has one.
ToList(),Count()and the other query terminals bind throughSystem.Linqalone, but their async twins are EF Core extension methods. When the file does not already import them (through a file, namespace or global using), the fixer addsusing Microsoft.EntityFrameworkCore;.SaveChangesAsync()andFindAsync()are instance members, so those rewrites add no using.
Analyzer Logic
- Reports only mapped sync methods on EF Core
DbContext,DbSet, orIQueryablesources. - Does not report plain in-memory
IEnumerablework,IQueryablechains proven to wrap an in-memory collection (see In-Memory Queries), or synchronous methods outside async contexts. - Does not report sync-looking operators that are part of an
IQueryableexpression tree and will be translated by the provider.