- Lab
-
Libraries: If you want this lab, consider one of these libraries.
- Core Tech
Following an Asynchronous Request Through ASP.NET Core
Globomantics runs a small logistics API: it stores shipments in EF Core, pings carrier services for live tracking status, and serves manifests to warehouse staff. The API compiles and passes casual testing, but under real traffic it stalls: a carrier ping blocks a thread instead of awaiting, a manifest endpoint builds an entire in-memory list before responding, a failed save leaves an unsaved shipment sitting in EF Core's change tracker, and an audit logging call blocks its caller's thread for every write by wrapping async work in Task.Run(...).Wait(). Your job is to follow one request at a time through the pipeline and make each hop actually asynchronous, cancellable, and resilient.
Lab Info
Table of Contents
-
Challenge
Step 1: Meet Globomantics and its stalled request pipeline
Globomantics: a logistics API that only looks asynchronous
Globomantics tracks parcels for a warehouse network. Its data model is deliberately small: a
Shipmentrecords a parcel's tracking number, carrier, and destination, and it's persisted throughShipmentDbContext, an EF CoreDbContextbacked here by the in-memory provider.Alongside the database sits
CarrierPingService, which stands in for the carriers Globomantics tracks parcels through. Its methods returnTaskandValueTaskand callersawaitthem, but whether they actually free a thread while they wait depends on how they're written underneath.The minimal API endpoints in
Program.cssit on top of both: they accept an HTTP request, askShipmentRepositoryfor data (which in turn may askCarrierPingServicefor a live status), and serialize a response. Every one of those hops is a place where async can be done correctly, or quietly undone. ## Following one request through the pipelineTrace a single
GET /shipments/{id}/statusrequest end to end: the endpoint delegate inProgram.cscallsShipmentRepository.GetShipmentStatusAsync, which queriesShipmentDbContextfor the shipment and then callsCarrierPingServicefor its carrier's live status before the endpoint responds.On paper this is a clean async chain. In practice, every hop has a way to undo it: an endpoint that blocks on
.Result, a repository that pulls a whole table into memory before filtering it in C#, a carrier ping that blocks a thread on what should be a non-blocking delay, and aCancellationTokenthat's accepted but never passed on. Each defeats async one hop at a time, and the effect compounds: a request that should free its thread at every hop instead holds onto it throughout, and keeps working after its client has gone. ## The five problems this lab fixes, in the order you'll fix them- Wrong async shape. A carrier ping that blocks a thread where it should await, a cache lookup that allocates a
Taskfor a hit that never needed one, and carrier checks that run one after another instead of fanning out. Fixed in Step 2. - Blocking EF Core calls. Repository queries and endpoints that call the database synchronously:
ToList()instead ofToListAsync(),.Resultinstead ofawait. Fixed in Step 3. - No cancellation, no timeout, no cleanup. A slow carrier can hang a request indefinitely, a disconnected client's work keeps running, and a failed save leaves an unsaved shipment in EF Core's change tracker. Fixed in Step 4.
- Buffered responses and fragile carrier checks. The manifest builds a full in-memory list before sending anything, and a batch carrier check fails on the first transient error because it never retries. Fixed in Step 5.
- A sync-over-async call that ties up threads. An audit log write wrapped in
Task.Run(...).Wait()blocks its caller for every write and wraps its failures in anAggregateException. Fixed in Step 5. ## A note on the in-memory database
The EF Core in-memory provider keeps this lab self-contained, but it isn't a real database. Its async methods complete synchronously, so you won't see a thread actually freed while a query runs, and it has no transactions, so there's nothing for a failed save to roll back. The checks verify that your code is written correctly, and that code is exactly what you'd ship against SQL Server or PostgreSQL, where those async calls really do wait on the network. ## How the rest of the lab is organized
Each step below works on one hop of the request pipeline you just traced. Step 2 stays entirely inside
CarrierPingService.cs, where you'll practice choosing betweenTask,ValueTask, andTask.WhenAllbefore those choices matter to a real request. Step 3 wiresShipmentRepositoryand the endpoints inProgram.csto genuine async EF Core calls. Step 4 threads aCancellationTokenfrom the request through the repository and carrier calls, and adds a timeout and a cleanup path for failed saves. Step 5 finishes the job: streaming, resilient concurrent carrier calls, and the sync-over-async audit write. - Wrong async shape. A carrier ping that blocks a thread where it should await, a cache lookup that allocates a
-
Challenge
Step 2: Build the foundational async helpers
Choosing the right shape for an async call
CarrierPingServiceis where Globomantics simulates talking to a carrier's API. Before wiring it into the rest of the application, get its own methods right in isolation.When you mark a method
async, the compiler rewrites it into a state machine. Eachawaiton an operation that hasn't finished yet is a point where the method returns to its caller, releasing the thread, and later resumes where it left off. A method that returnsTaskbut does its waiting with a blocking call never reaches one of those points, so it gives you none of async's benefit: the calling thread is held exactly as if the method were synchronous. ## Not every async result needs a TaskTaskis a class, so returning one allocates, even when the method already knows its answer before it starts.ValueTask<T>exists for exactly that case: a method that usually finishes synchronously (a cache hit) but occasionally needs to actually await something (a cache miss). When anasync ValueTask<T>method returns without awaiting anything incomplete, the result travels inside theValueTaskitself and nothing is allocated.GetCachedStatusAsyncis written against a cache, so it's the right place to draw that line.The cache is a
ConcurrentDictionaryrather than a plainDictionarybecause concurrent requests read and write it at the same time. And one rule comes withValueTask: await it exactly once. Unlike aTask, it can be backed by a reusable object, so awaiting it twice, or holding on to it to await later, can produce the wrong result. If you need to do either, call.AsTask()first. ## Fanning out instead of queuing upGlobomantics sometimes needs a status from every carrier it works with, not just one. Awaiting each carrier's ping in turn adds their latencies together; starting every call first and then awaiting them all together lets their latencies overlap.
Task.WhenAllis the composition that expresses that: give it a collection of already-started tasks, and it completes when the slowest one does, not when the sum of all of them does. -
Challenge
Step 3: Wire the repository and endpoints to real async EF Core
From a blocking repository to a genuinely async one
ShipmentRepositorysits between the endpoints andShipmentDbContext. EF Core's async query methods, such asFirstOrDefaultAsyncandToListAsync, exist so that a database round trip never has to block the thread that started it. In this step you'll use them for the repository's reads, and addAsNoTracking()to each one: these queries only read, so there's no reason for EF Core to spend time and memory tracking the entities they return. The write side,SaveChangesAsync, comes in Step 4, where it needs failure handling as well. ## The same fix, for a list instead of a single rowListShipmentsAsyncis the list-returning form of the lookup you just wrote: the synchronousToList()would block whereToListAsync()belongs. The fix is smaller this time, but it matters just as much, because the dashboard that calls this method is exactly the kind of endpoint that gets hit by several warehouse terminals at once.This method returns every row, which is fine for a lab-sized table. A production dashboard would page the query, with
SkipandTakeor keyset paging, so that a growing table doesn't mean an ever-growing response. ## The blocking call at the very top of the pipelineFixing the repository doesn't help if the endpoint calling it still blocks. Reading
.Resultoff a repositoryTaskin an endpoint blocks the request thread until the query underneath completes, which is the exact problem you just fixed one layer down, reintroduced one layer up. Anasyncendpoint delegate that awaits its repository calls all the way through is what actually lets the request thread go free while the database does its work. -
Challenge
Step 4: Propagate cancellation, timeouts, and failed-save cleanup
A CancellationToken is only useful where it's passed
ASP.NET Core doesn't hand a minimal API handler a
CancellationTokenautomatically. A handler gets one by declaring aCancellationTokenparameter, which ASP.NET Core binds toHttpContext.RequestAborted. That token fires when the client disconnects. It doesn't fire because a request has run too long unless you add the Request Timeouts middleware, so any timeout you care about is one you enforce yourself.The
GET /shipments/{id}/statusendpoint inRegisterTrackingEndpoints, inProgram.cs, is already written this way: it declares the parameter and passes the token toGetShipmentStatusAsync. From there, the token is only as useful as the calls it reaches. Passed intoFirstOrDefaultAsync, it can stop a query mid-flight. Passed into the simulated carrier call, which stands in for an outbound HTTP request, it can abandon a hanging carrier. Combined withCancellationTokenSource.CreateLinkedTokenSourceandCancelAfter, it can enforce a timeout Globomantics chooses itself, on top of the client's own disconnect. ## Telling a timeout from a cancellationWhen the linked token fires,
PingCarrierWithTimeoutAsyncthrows anOperationCanceledExceptionwhether the timeout elapsed or the caller cancelled. That's all the check for Task 4.1 needs, but production code often needs to tell them apart, because a timeout is a failure worth logging and a disconnect usually isn't. The usual pattern is to catchOperationCanceledExceptionwhen!ct.IsCancellationRequested, meaning only the timeout fired, and throw aTimeoutExceptionin its place.Next,
GetShipmentStatusAsyncputs the timeout to use. It's the method the status endpoint calls, so it's where the request's token and the per-carrier timeout meet. ## Cancellation during a write is different from cancellation during a readCancelling a read just means the caller stops waiting; nothing was changed. A write is different, but not because a cancelled
SaveChangesAsyncleaves half a row behind: on a relational database the save runs in a transaction, so it commits completely or not at all. The risk is in EF Core's change tracker.Addmarks the new shipment asAddedbefore the save runs, and if the save throws, it stays that way. The nextSaveChangesAsyncon the same context would insert it after all.In a typical ASP.NET Core app, a
DbContextis scoped to one request and discarded with it, so a stale entity rarely survives long enough to matter. It does matter when a context outlives a single unit of work, such as in a background job, a retry loop that reuses the context, or a long-lived scope. Detaching the entity when a save fails makesRegisterShipmentAsyncsafe in all of those cases. -
Challenge
Step 5: Stream, compose concurrently, and stop the thread pool from starving
Sending results as they arrive
The warehouse manifest can run to thousands of shipments. Building that whole answer as a
List<Shipment>before sending anything means the client waits for every row before it sees the first one.IAsyncEnumerable<T>, combined with EF Core'sAsAsyncEnumerable()and anawait foreachloop thatyield returns each item, lets the response start streaming as soon as the first rows come back from the database instead.The
GET /shipments/manifestendpoint inProgram.csis already wired for this: it returnsStreamShipmentManifestAsyncdirectly, and ASP.NET Core serializes anIAsyncEnumerable<T>result as a JSON array, writing to the response as items are produced. Two trade-offs come with it. ReturningAsAsyncEnumerable()directly would stream just as well; an explicit loop is the shape you need when each row has to be transformed or filtered on the way out. And a streamed response holds its database connection open until the last row is written, so a slow client keeps a connection busy for longer. ## Concurrency and resilience togetherChecking several carriers at once, the way
PingAllCarriersAsyncalready does, is only half of what a production check needs; one transient failure from one carrier shouldn't fail the whole batch either.PingCarriersWithRetryAsyncneeds both properties at once: every carrier's check still has to start before any of them are awaited, and each one individually needs to retry on failure without holding up the others.A hand-written retry loop is the clearest way to see how that works. In a production app making real HTTP calls, you'd typically get the same behavior, along with circuit breakers and timeouts, from
Microsoft.Extensions.Http.Resilience, which builds on Polly and plugs intoHttpClient. ## The blocking call that was never on the request pathThe last bug in Globomantics isn't in the request pipeline you've been tracing. It's in
AuditLogger, which records what happened to each shipment. Calling async code and then blocking on it, as inTask.Run(() => WriteAuditEntryAsync(...)).Wait(), is known as sync-over-async. It doesn't lose the write's exceptions:.Wait()rethrows them, wrapped in anAggregateException. The real cost is threads. The caller's thread blocks for the whole write whileTask.Runqueues the work onto a second thread-pool thread. Under load, every in-flight write holds a thread that other requests need, and because the thread pool only adds threads gradually, requests queue up waiting for one even while the CPU sits idle. That's thread-pool starvation. -
Challenge
Step 6: Run the application
Every task is done, and each one was graded the moment you completed it. What you haven't done yet is watch the whole application run at once, which is the only place the pieces you wrote separately become a single working program.
Start the application
Open the Terminal tab and run:
dotnet run --project src/Lab.csprojThe program runs in the terminal and prints its output there. Every behavior it shows is code you wrote in the previous steps, so read the output against what each task asked for. That's the whole lab, working as one program. ### Run the checks yourself
The lab ran each task's test for you as you went, but they're ordinary tests and you can run them whenever you like. The whole suite:
dotnet test tests/Lab.Tests.csproj --nologo --verbosity quietOr one task at a time:
bash runTest.sh Task_2_1Each failure names the task it belongs to, so the output tells you which step to go back to rather than which line to stare at. One thing worth trying before you finish: break something on purpose and watch the matching check fail, then put it back. Knowing what a failure looks like is worth as much as knowing what a pass looks like, and it costs you nothing here.
When you're done, everything you wrote is still in the editor tabs. The lab is yours to keep reading.
About the author
Real skill practice before real-world application
Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.
Learn by doing
Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.
Follow your guide
All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.
Turn time into mastery
On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.