Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

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 platform
Lab Info
Level
Advanced
Last updated
Oct 20, 2026
Duration
1h 15m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. 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 Shipment records a parcel's tracking number, carrier, and destination, and it's persisted through ShipmentDbContext, an EF Core DbContext backed here by the in-memory provider.

    Alongside the database sits CarrierPingService, which stands in for the carriers Globomantics tracks parcels through. Its methods return Task and ValueTask and callers await them, but whether they actually free a thread while they wait depends on how they're written underneath.

    The minimal API endpoints in Program.cs sit on top of both: they accept an HTTP request, ask ShipmentRepository for data (which in turn may ask CarrierPingService for 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 pipeline

    Trace a single GET /shipments/{id}/status request end to end: the endpoint delegate in Program.cs calls ShipmentRepository.GetShipmentStatusAsync, which queries ShipmentDbContext for the shipment and then calls CarrierPingService for 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 a CancellationToken that'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

    1. Wrong async shape. A carrier ping that blocks a thread where it should await, a cache lookup that allocates a Task for a hit that never needed one, and carrier checks that run one after another instead of fanning out. Fixed in Step 2.
    2. Blocking EF Core calls. Repository queries and endpoints that call the database synchronously: ToList() instead of ToListAsync(), .Result instead of await. Fixed in Step 3.
    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.
    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.
    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 an AggregateException. 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 between Task, ValueTask, and Task.WhenAll before those choices matter to a real request. Step 3 wires ShipmentRepository and the endpoints in Program.cs to genuine async EF Core calls. Step 4 threads a CancellationToken from 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.

  2. Challenge

    Step 2: Build the foundational async helpers

    Choosing the right shape for an async call

    CarrierPingService is 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. Each await on 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 returns Task but 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 Task

    Task is 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 an async ValueTask<T> method returns without awaiting anything incomplete, the result travels inside the ValueTask itself and nothing is allocated. GetCachedStatusAsync is written against a cache, so it's the right place to draw that line.

    The cache is a ConcurrentDictionary rather than a plain Dictionary because concurrent requests read and write it at the same time. And one rule comes with ValueTask: await it exactly once. Unlike a Task, 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 up

    Globomantics 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.WhenAll is 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.

  3. Challenge

    Step 3: Wire the repository and endpoints to real async EF Core

    From a blocking repository to a genuinely async one

    ShipmentRepository sits between the endpoints and ShipmentDbContext. EF Core's async query methods, such as FirstOrDefaultAsync and ToListAsync, 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 add AsNoTracking() 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 row

    ListShipmentsAsync is the list-returning form of the lookup you just wrote: the synchronous ToList() would block where ToListAsync() 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 Skip and Take or keyset paging, so that a growing table doesn't mean an ever-growing response. ## The blocking call at the very top of the pipeline

    Fixing the repository doesn't help if the endpoint calling it still blocks. Reading .Result off a repository Task in 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. An async endpoint 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.

  4. 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 CancellationToken automatically. A handler gets one by declaring a CancellationToken parameter, which ASP.NET Core binds to HttpContext.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}/status endpoint in RegisterTrackingEndpoints, in Program.cs, is already written this way: it declares the parameter and passes the token to GetShipmentStatusAsync. From there, the token is only as useful as the calls it reaches. Passed into FirstOrDefaultAsync, 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 with CancellationTokenSource.CreateLinkedTokenSource and CancelAfter, it can enforce a timeout Globomantics chooses itself, on top of the client's own disconnect. ## Telling a timeout from a cancellation

    When the linked token fires, PingCarrierWithTimeoutAsync throws an OperationCanceledException whether 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 catch OperationCanceledException when !ct.IsCancellationRequested, meaning only the timeout fired, and throw a TimeoutException in its place.

    Next, GetShipmentStatusAsync puts 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 read

    Cancelling a read just means the caller stops waiting; nothing was changed. A write is different, but not because a cancelled SaveChangesAsync leaves 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. Add marks the new shipment as Added before the save runs, and if the save throws, it stays that way. The next SaveChangesAsync on the same context would insert it after all.

    In a typical ASP.NET Core app, a DbContext is 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 makes RegisterShipmentAsync safe in all of those cases.

  5. 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's AsAsyncEnumerable() and an await foreach loop that yield returns each item, lets the response start streaming as soon as the first rows come back from the database instead.

    The GET /shipments/manifest endpoint in Program.cs is already wired for this: it returns StreamShipmentManifestAsync directly, and ASP.NET Core serializes an IAsyncEnumerable<T> result as a JSON array, writing to the response as items are produced. Two trade-offs come with it. Returning AsAsyncEnumerable() 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 together

    Checking several carriers at once, the way PingAllCarriersAsync already does, is only half of what a production check needs; one transient failure from one carrier shouldn't fail the whole batch either. PingCarriersWithRetryAsync needs 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 into HttpClient. ## The blocking call that was never on the request path

    The 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 in Task.Run(() => WriteAuditEntryAsync(...)).Wait(), is known as sync-over-async. It doesn't lose the write's exceptions: .Wait() rethrows them, wrapped in an AggregateException. The real cost is threads. The caller's thread blocks for the whole write while Task.Run queues 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.

  6. 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.csproj
    

    The 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 quiet
    

    Or one task at a time:

    bash runTest.sh Task_2_1
    

    Each 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

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

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.

Get started with Pluralsight