Looking at c#'s Task<IEnumerable<T>>
Damiaan

Damiaan @dampee

About: chaos entertainment octopus

Location:
Antwerp, Belgium
Joined:
Jun 20, 2019

Looking at c#'s Task<IEnumerable<T>>

Publish Date: May 26
0 0

On the surface, Task<IEnumerable<T>> looks harmless — you're just asynchronously returning a list of things. But under the hood, there's a mismatch between what the caller sees and what the code actually does.

Let’s unpack it.

1. IEnumerable<T> is Lazy by Design

The IEnumerable interface represents a deferred execution model. It doesn’t hold data — it describes a computation that yields data when enumerated.

IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
}

Here, GetNumbers() doesn’t do anything until the caller starts iterating. It’s not a collection — it’s a generator.

2. Task<IEnumerable<T>> Suggests Lazy + Async — But It’s Not

Let’s say you write:

public async Task<IEnumerable<User>> GetUsersAsync()
{
return await _context.Users.ToListAsync();
}

You're returning a fully materialized list, but your method signature doesn’t say that. To the caller, this looks like:

"I'm going to give you a lazily-evaluated collection — once the async operation is done."

This is misleading because:

  • The data is already in memory.
  • IEnumerable<T> can be enumerated multiple times, each of which may repeat logic or side effects.
  • But here, re-enumerating just re-reads the in-memory list — not re-executes anything — and that behavior is hidden.

3. Async Methods use State Machines — But not for IEnumerable

When you await in a method, the compiler rewrites it into a state machine — tracking the position across awaits. However:

  • This async state machine wraps only the outer method body.
  • It doesn’t know how or when the returned IEnumerable<T> will be used.

So if a developer thinks this is a streaming or delayed-fetch API — because IEnumerable<T> can be — they're wrong. It’s just a preloaded list.

4. The Real Danger: Confusion and Redundant Work

Because the return type hides the fact that the data is already materialized, consumers often:

var users = (await repo.GetUsersAsync()).ToList(); // unnecessary!

This results in a copy of a list that's already a list.

Worse, if the internal repo implementation is ever refactored to yield from a generator, the caller’s .ToList() could re-execute database queries (in a non-buffered scenario).

Clearer Alternatives

To avoid these issues:

  • Return IReadOnlyList<T> to make it explicit that the result is fully realized.
  • Return IAsyncEnumerable<T> if you want streaming, lazy loading, or pipelining behavior.

Comments 0 total

    Add comment