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.