Async, cancellation & database work
Keep request handling responsive and learn which operations should—and should not—run concurrently.
Here's what you'll be able to do.
- Use async all the way through an I/O-bound operation
- Pass request cancellation to EF Core
- Avoid parallel operations on one DbContext
Understand async controller signatures and the EF Core list action.
Understand the idea
Async I/O allows a request to wait for an external operation without occupying a thread for the entire wait. It does not make the database execute a query faster, and it is not a substitute for filtering or indexing.
The practical pattern is simple: use asynchronous database APIs, await their tasks, and pass a cancellation token. Avoid blocking on asynchronous work with Result or Wait. Then measure the underlying operation if it remains slow.
Choose your environment
Open TaskBoard.csproj from the example download. Install the ASP.NET and web development workload and a Visual Studio release supporting .NET 10. Select the TaskBoard launch profile and press F5. Use Solution Explorer to find the files named below.
Install the .NET 10 SDK and C# Dev Kit. Open the TaskBoard folder in VS Code. Run dotnet restore, then dotnet run in the integrated terminal. Browse http://localhost:5300. Open files with Ctrl+P (Cmd+P on macOS).
Follow a task through the call chain
An async action returns Task<IActionResult>. Await suspends the method until the database result is available and resumes it to build the response. The actual query runs when the async materialization method is invoked, not when you first build the IQueryable.
Use the asynchronous API provided by the I/O library. Wrapping a synchronous EF call in Task.Run uses a thread-pool thread and generally adds overhead rather than providing the same scalable I/O behavior.
public async Task<IActionResult> Index(
string? q, int page = 1, CancellationToken ct = default)
{
var tasks = await db.Tasks.AsNoTracking()
.Where(t => t.OwnerId == OwnerId)
.OrderBy(t => t.Id).Take(6)
.ToListAsync(ct);
// Package the result in the page model before rendering.
return View(new TaskListPage(tasks, q, 1, 1, tasks.Count));
}This reduced action illustrates async flow only. The downloadable application contains the full search and pagination implementation.
Propagate cancellation
MVC can bind a CancellationToken parameter to request cancellation. Pass it through your service and into EF calls so work can be abandoned when the request is no longer useful. Cancellation is cooperative: each operation must observe the token.
Do not turn every OperationCanceledException into a generic server-failure alert. A client disconnect is different from a broken database. Logging and error handling should preserve that distinction.
public interface IBoardSummary
{
Task<Summary> GetAsync(string ownerId, CancellationToken ct);
}
// Inside the service:
var total = await query.CountAsync(ct);
var completed = await query.CountAsync(t => t.IsComplete, ct);Respect the context’s concurrency boundary
BoardSummary executes its two queries sequentially. Calling Task.WhenAll on both queries against the same DbContext is not supported. A context is a unit of work with mutable tracking and connection state.
If independent operations truly benefit from concurrency, give them independent contexts through a suitable factory and understand the extra database load. For two counts, a single aggregate query may be a better optimization than parallel requests. First establish that the extra complexity is justified.
var total = await query.CountAsync(ct);
var done = await query.CountAsync(t => t.IsComplete, ct);
return new Summary(total, done);Distinguish I/O work from CPU work
Waiting on SQL, a file stream, or an HTTP service is I/O-bound. Parsing a huge in-memory document or performing image transformations is CPU-bound. Await does not automatically move CPU work off the request thread.
Keep ordinary requests bounded. Long-running jobs usually need a background queue, durable state, and an observable status rather than a request that stays open indefinitely. Do not fire-and-forget a task that depends on the request-scoped DbContext; that context will be disposed when the request ends.
// Avoid blocking async I/O:
// var tasks = query.ToListAsync().Result;
// Avoid concurrent work on one context:
// await Task.WhenAll(query.CountAsync(), query.ToListAsync());
// Avoid request-scoped fire-and-forget work:
// _ = db.SaveChangesAsync();Make it your own.
Trace the cancellation token from BoardApiController.Summary through BoardSummary.GetAsync into both count calls. Add a comment explaining why the calls are sequential.
See the solution & reasoning
The action receives the request token, passes it to the service, and each CountAsync receives the same token. The calls are awaited one at a time because both use the same scoped BoardDb. This preserves EF Core’s single-operation-at-a-time requirement.
When something doesn't work
“A second operation was started on this context” often means a missing await or parallel use of one context.
An async method without await may simply be returning a task, or it may be incorrectly marked async. Read the behavior rather than adding a meaningless delay.
Cancellation can happen after a write has reached the database. Do not assume a canceled response proves the transaction did not commit.
Can two EF Core queries run concurrently on the same DbContext?
That's another skill in your toolkit.
Your progress is saved in this browser. Sign in to save completion to your account.