# 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:

```csharp
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.

```csharp
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.

```csharp
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.

```csharp
static IEnumerable<int> GetNumbers()
{
    Console.WriteLine("Starting");
    yield return 1;
    Console.WriteLine("Continuing");
    yield return 2;
}
```

Calling the method does not print `Starting`.

```csharp
var numbers = GetNumbers();
Console.WriteLine("Method called");
```

The body begins when you enumerate it.

```csharp
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.

```csharp
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:

```csharp
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:

```csharp
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()`.

```csharp
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.

```csharp
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`.

```csharp
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.

```csharp
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.

```csharp
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".

```csharp
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.

```csharp
private readonly Lazy<ReportCache> _reportCache =
    new(() => LoadReportCache());
```

Nothing calls `LoadReportCache()` until somebody asks for `_reportCache.Value`.

```csharp
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.

```csharp
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#.

```csharp
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](https://learn.microsoft.com/en-us/dotnet/standard/linq/deferred-execution-lazy-evaluation)

[Microsoft Learn - yield statement](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/yield)

[Microsoft Learn - IQueryable Interface](https://learn.microsoft.com/en-us/dotnet/api/system.linq.iqueryable-1)

[Microsoft Learn - EF Core Client vs Server Evaluation](https://learn.microsoft.com/en-us/ef/core/querying/client-eval)tion

[Microsoft Learn - Generate and consume async streams](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/generate-consume-asynchronous-stream)te and consume async streams

[Microsoft Learn - Asynchronous programming scenarios](https://learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming/async-scenarios)soft Learn - Asynchronous programming scenarios

[Microsoft Learn - Lazy Initialization](https://learn.microsoft.com/en-us/dotnet/framework/performance/lazy-initialization)
