The C# Code That Looks Synchronous But Isn’t

Some of the most confusing C# bugs do not come from complicated syntax. They come from code that looks like it runs now, but actually runs later. Thats where IEnumerable<T>, LINQ, yield return, IQueryable<T>, async streams, and Lazy<T> all become dangerous in the same way. They separate the place where code is described from the place where code is executed. In small examples, that feels elegant. In production code, it can mean duplicate HTTP calls, unexpected SQL, hidden latency, disposed context errors, and side effects that happen twice.
This is not about avoiding those features. Theyre good features. The problem starts when a codebase stops being honest about when work actually happens.
A LINQ query is often just a recipe
This code looks like it filters the list:
var activeUsers = users
.Where(user =>
{
Console.WriteLine($"Checking {user.Id}");
return user.IsActive;
});
Console.WriteLine("Query created");
The Where has not checked anything yet. It has built a sequence. The work starts when something enumerates that sequence.
foreach (var user in activeUsers)
{
Console.WriteLine(user.Id);
}
That distinction sounds small, but it changes how you read the code. The Where line is not the execution point. The foreach is. A call to ToList(), ToArray(), Count(), First(), Single(), Any(), or foreach can be the real trigger.
It gets worse when the sequence is enumerated twice.
var activeUsers = users.Where(user =>
{
Console.WriteLine($"Checking {user.Id}");
return user.IsActive;
});
var firstCount = activeUsers.Count();
var secondCount = activeUsers.Count();
That predicate runs twice. If the predicate only checks memory, you waste CPU. If it logs, mutates state, calls a service, or touches a database through another abstraction, you have a real bug hiding behind innocent syntax. The practical rule is simple, keep side effects out of LINQ queries. A LINQ query should describe data transformation. Once it starts sending emails, writing logs, mutating objects, or making network calls, the timing becomes too easy to misread.
yield return turns a method into a suspended workflow
Iterator methods are another place where C# reads more eagerly than it behaves.
static IEnumerable<int> GetNumbers()
{
Console.WriteLine("Starting");
yield return 1;
Console.WriteLine("Continuing");
yield return 2;
}
Calling the method does not print Starting.
var numbers = GetNumbers();
Console.WriteLine("Method called");
The body begins when you enumerate it.
foreach (var number in numbers)
{
Console.WriteLine(number);
}
The method runs until it reaches the first yield return. Then it pauses. On the next iteration, it resumes from where it left off. Thats the important point. This isnt a normal method that returns a completed collection. It is a resumable state machine generated by the compiler. Thats great when you want streaming behaviour. It avoids building a whole collection up front. It also means try, finally, open files, database readers, and other lifetime-sensitive code need more care. The resource may stay alive for as long as the sequence is being enumerated, not just for the duration of the original method call.
This is why returning IEnumerable<T> from a method can be unclear. Sometimes it means "here is an in-memory collection". Sometimes it means "here is a deferred pipeline that will execute later". The type alone does not tell you enough.
IQueryable<T> changes who owns the code
IQueryable<T> is where things get stranger.
var query = db.Orders
.Where(order => order.Total > 100)
.Select(order => new
{
order.Id,
order.Total
});
This looks like ordinary C#. With Entity Framework Core, it is closer to a query description. The expression tree is handed to a provider, and that provider decides how to execute it. For a relational provider, that usually means translating as much as possible to SQL. Thats powerful, but it means your C# expression is not always executed by the CLR. Some of it may become SQL. Some of it may become parameters. Some of it may be rejected because the provider cannot translate it.
This line looks harmless:
var query = db.Orders
.Where(order => IsLargeOrder(order.Total));
If IsLargeOrder is your own C# method, EF Core cannot automatically translate that method body into SQL. In modern EF Core, unsupported client evaluation inside a filter causes a runtime exception instead of silently pulling everything into memory. Thats a good failure, but it still surprises people because the code compiled fine.
A projection is different:
var query = db.Orders
.Select(order => new
{
order.Id,
Label = BuildLabel(order.Total)
});
EF Core allows client evaluation in the top-level projection. It can fetch the required data from the database, then run BuildLabel in your process. That can be exactly what you want. It can also hide work at the end of a query that looks fully database-backed.
The dangerous boundary is AsEnumerable().
var results = db.Orders
.Where(order => order.Total > 100)
.AsEnumerable()
.Where(order => IsLargeOrder(order.Total))
.ToList();
Everything before AsEnumerable() is still provider backed. Everything after it is LINQ to Objects. You have crossed from database query building into in-process enumeration. That can be a valid choice when the result set is known to be small. It is a production issue when someone accidentally moves the boundary too early.
Async streams make loops look local
await foreach is one of those features that reads beautifully.
await foreach (var message in ReadMessagesAsync(stopToken))
{
await ProcessAsync(message, stopToken);
}
The code looks like a normal loop with an await. The runtime behaviour is closer to pulling values from an asynchronous source one at a time. Each iteration may involve I/O. The next value may require a network call, a database read, a queue receive, or a timer.
An async iterator can also hide work behind yield return.
static async IAsyncEnumerable<Order> ReadOrdersAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation]
CancellationToken stopToken = default)
{
var page = 1;
while (!stopToken.IsCancellationRequested)
{
var orders = await GetOrdersPageAsync(page, stopToken);
if (orders.Count == 0)
{
yield break;
}
foreach (var order in orders)
{
yield return order;
}
page++;
}
}
That method does not fetch all orders when it is called. The fetching happens as the consumer asks for the next item. That is the right shape for streaming. It also means cancellation, error handling, retries, logging, and resource cleanup need to be designed around the enumeration, not just the method call. The name should say what is happening. GetOrdersAsync could mean "return all orders", StreamOrdersAsync is clearer when the method returns IAsyncEnumerable<Order> and work continues during enumeration.
LINQ over async is a trap
This one bites a lot of good developers.
var tasks = userIds.Select(id => GetUserAsync(id));
var users = await Task.WhenAll(tasks);
That can be fine, but Select is still lazy. The calls to GetUserAsync happen when the sequence is enumerated by Task.WhenAll. If you enumerate tasks twice, you can create two sets of tasks and call the remote service twice.
var tasks = userIds.Select(id => GetUserAsync(id));
var first = await Task.WhenAll(tasks);
var second = await Task.WhenAll(tasks);
That second line does not await the same work again. It creates new work. If GetUserAsync calls an API, you just made the API calls twice.
Materialise the tasks when the intent is "start this batch now".
var tasks = userIds
.Select(id => GetUserAsync(id))
.ToArray();
var users = await Task.WhenAll(tasks);
Now you have a stable set of tasks. You can pass it around without accidentally rebuilding the sequence. This is one of those places where ToArray() is not just a performance choice. It documents the execution boundary.
Lazy<T> hides the expensive moment
Lazy<T> is another version of the same idea. The object exists, but the value does not.
private readonly Lazy<ReportCache> _reportCache =
new(() => LoadReportCache());
Nothing calls LoadReportCache() until somebody asks for _reportCache.Value.
var cache = _reportCache.Value;
That access can now become the slow line in your request path. It might read a file, hit a database, compile a regex, hydrate a large object graph, or throw an exception. The field declaration looked cheap. The first .Value access pays the bill.
Thats not a reason to avoid Lazy<T>. Its a reason to be deliberate. If the first request after deployment should not pay the initialisation cost, warm it explicitly. If the value can fail to initialise, treat .Value as a meaningful execution point rather than a property access you skim past during review.
Materialisation is a design decision
A lot of C# code gets clearer when you make execution boundaries obvious.
var eligibleOrders = await db.Orders
.Where(order => order.Total > 100)
.OrderByDescending(order => order.CreatedAt)
.Take(100)
.ToListAsync(stopToken);
That ToListAsync line says, the database query happens here, and after this point we are working with memory.
Then the next stage can be ordinary C#.
var summaries = eligibleOrders
.Select(order => BuildSummary(order))
.ToList();
Theres nothing clever about this. Thats the point. You can look at the code and see where the database work ends, where in-process work begins, and where the collection is materialised.
The alternative is a chain that mixes provider backed query code, local helper methods, async work, and deferred enumeration. It may compile. It may even pass tests with small data. Under production load, it becomes hard to tell which line is responsible for the cost.
How I’d review this in real code
When I see IEnumerable<T>, I ask whether it represents a finished collection or a deferred pipeline. When I see IQueryable<T>, I ask where the SQL boundary is. When I see Select(id => SomeAsyncCall(id)), I check whether the sequence is materialised before it is reused. When I see yield return, I look for resource lifetime issues. When I see Lazy<T>.Value, I treat it as a method call that may be expensive. The fixes are usually simple. Use ToList(), ToArray(), ToListAsync(), or AsEnumerable() deliberately. Name streaming methods as streaming methods. Do not return IQueryable<T> across too many layers unless your architecture really wants query composition to leak that far. Avoid side effects inside LINQ. Keep database expressions translatable until the point where you intentionally switch to memory.
The deeper lesson is that C# has several features that delay execution. They are designed that way. The runtime is not tricking you. The compiler is not broken. The problem is reading deferred code as if it were immediate code. Extreme C# is not always about raw speed. Sometimes it is about knowing which line actually runs.
Microsoft Learn - Deferred execution and lazy evaluation
Microsoft Learn - yield statement
Microsoft Learn - IQueryable Interface
Microsoft Learn - EF Core Client vs Server Evaluationtion
Microsoft Learn - Generate and consume async streamste and consume async streams
Microsoft Learn - Asynchronous programming scenariossoft Learn - Asynchronous programming scenarios




