Asynchronous Processing
Database Engine
Entity Processing
Real-time Data Handling
Concurrent Programming

Asynchronously process entities as they are returned from the database engine

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

If you want to process database entities as they arrive instead of waiting for the entire result set to load, the key idea is streaming rather than materializing. In asynchronous systems, that usually means iterating rows through an async API, applying work incrementally, and being careful not to turn the stream back into an in-memory list by accident. The performance benefit is lower memory pressure and earlier processing, not magical parallelism by itself.

Streaming Is Different from ToListAsync

A common anti-pattern is running an async query and then materializing everything before processing:

csharp
1var items = await dbContext.Orders.ToListAsync();
2foreach (var item in items)
3{
4    Process(item);
5}

This is asynchronous database access, but it is not incremental processing. The application still waits for the entire result set before the loop starts.

If the real goal is “start handling entities as soon as the database returns them,” you need a streaming shape instead.

Use Async Enumeration in EF Core

In modern C#, AsAsyncEnumerable() is a natural way to process rows progressively.

csharp
1await foreach (var order in dbContext.Orders
2    .AsNoTracking()
3    .AsAsyncEnumerable())
4{
5    await ProcessOrderAsync(order);
6}

This pattern allows your code to receive entities one by one as the underlying provider yields them. It is a much better match for large result sets, background processing, or pipelines where each entity can be handled independently.

The AsNoTracking() call is often useful here because it reduces change-tracking overhead when you only need read-and-process behavior.

Async Does Not Automatically Mean Parallel

Another important distinction: asynchronous streaming does not automatically process many entities in parallel. The await foreach loop above is still sequential unless you explicitly introduce concurrency.

That is often the right default, because uncontrolled parallelism can overwhelm:

  • the database connection
  • downstream APIs
  • CPU-bound processing
  • memory

If you need concurrency, add it deliberately with limits rather than assuming async iteration already did it for you.

Bounded Parallel Processing Pattern

A controlled concurrency pattern might collect a limited number of tasks at a time.

csharp
1var tasks = new List<Task>();
2const int maxParallel = 8;
3
4await foreach (var order in dbContext.Orders.AsNoTracking().AsAsyncEnumerable())
5{
6    tasks.Add(ProcessOrderAsync(order));
7
8    if (tasks.Count >= maxParallel)
9    {
10        var finished = await Task.WhenAny(tasks);
11        tasks.Remove(finished);
12    }
13}
14
15await Task.WhenAll(tasks);

This lets rows begin processing as they arrive while still imposing backpressure on the pipeline.

Beware of Hidden Buffering in the ORM or Driver

Not every stack streams results the way you assume. Some ORMs or providers buffer more than expected, and some query shapes force materialization.

That means the right engineering move is not only to write an async loop, but also to confirm whether the database provider truly supports incremental result consumption for that query path.

If the provider buffers everything first, then the code may look like streaming while behaving like bulk loading.

Transactions and Connection Lifetime Matter

When streaming results, the database connection typically stays open for the duration of the enumeration. That has consequences:

  • the connection is occupied longer
  • transaction scope can stay open longer
  • slow processing can hold database resources unnecessarily

If each entity triggers expensive external work, consider decoupling the phases: read identifiers from the database, queue work, and then process outside the live query stream when possible.

That prevents long-lived database resource usage from becoming the bottleneck.

Use the Right Tool for the Workload

Streaming is best when:

  • the result set is large
  • each entity can be processed independently
  • memory footprint matters
  • you want earlier time-to-first-result

Materializing everything can still be fine when:

  • the result set is small
  • processing order requires full collection context
  • the downstream algorithm needs random access over the whole set

So the right answer is workload-dependent, not ideological.

Common Pitfalls

The most common mistake is calling ToListAsync() and believing the subsequent loop is “processing as rows arrive.” It is not.

Another mistake is adding uncontrolled parallelism on top of streaming and then overwhelming the database or downstream systems.

Developers also forget that streaming keeps the database connection busy while processing continues, which can create pressure on connection pools.

Summary

  • To process entities as they are returned, prefer async streaming over full materialization.
  • In EF Core, AsAsyncEnumerable() plus await foreach is the standard pattern.
  • Async iteration is not automatically parallel; add concurrency deliberately and with limits.
  • Confirm whether your ORM or provider truly streams results or buffers them internally.
  • Streaming improves memory use and time-to-first-processing, but it also extends connection lifetime and must be designed carefully.

Course illustration
Course illustration

All Rights Reserved.