The request is an object lifetime, not just a code path
ASP.NET Core creates a service scope per request and disposes it when the response completes. Everything registered as scoped — your DbContext, a unit of work, a per-request cache, anything holding a connection — is disposed at that moment, and HttpContext stops being something you are allowed to touch. The framework documents this bluntly: HttpContext is not thread-safe, and reading its properties outside of request processing can throw.
So when work you started inside an action keeps running after the action returned, it is running past the end of everything it captured. The code compiles, the happy path in development often works because the response has not finished flushing yet on a fast local loop, and it fails in production under load where the timing is different. That timing sensitivity is why these bugs arrive as sporadic null references rather than as a consistent failure.
Two different exceptions with two different causes
Separating them is most of the diagnosis.
A NullReferenceException off IHttpContextAccessor.HttpContext means the accessor found nothing to give you. The accessor is backed by async-local storage, which flows with the logical call context during the request and is cleared when the request finishes. A task that outlives the request either finds it cleared, or was started in a way that never inherited the flow at all, and the property is simply null.
An ObjectDisposedException from a DbContext or a connection means the request scope was disposed while you still held a reference to something inside it. Nothing cleared your field; the object is still there and is now dead. This one is worse because it can also appear during the request when two concurrent operations share one non-thread-safe scoped instance, and then you get corrupted state rather than a clean exception.
[HttpPost("orders")]
public async Task<IActionResult> Create(OrderRequest req)
{
_db.Orders.Add(Map(req));
await _db.SaveChangesAsync();
_ = Task.Run(async () =>
{
var user = _accessor.HttpContext!.User.Identity!.Name;
_db.AuditEntries.Add(new AuditEntry(user));
await _db.SaveChangesAsync();
});
return Accepted();
}
Trace it against a request that completes in 20 ms while the audit write takes 200 ms. At 20 ms the response is done and the scope is disposed. At some point after that the lambda runs, dereferences a null HttpContext, and throws inside a task nobody is observing — so there is no 500, no entry in your request telemetry, and no audit row. The endpoint reports success and the side effect never happened.
Nobody is holding your task
The discarded Task is the second half of the problem, independent of HttpContext. Because the host has no reference to it, three things follow. Graceful shutdown drains in-flight requests and stops hosted services, but it knows nothing about a detached task, so a deployment can kill the work mid-flight with no log line. An exception inside it is unobserved rather than fatal — since .NET Framework 4.5 an unobserved task exception does not terminate the process — so failures are simply invisible unless you have wired up TaskScheduler.UnobservedTaskException. And there is no backpressure at all: a traffic spike starts unbounded work on the thread pool, which competes with request processing for the very threads that serve traffic.
async void deserves its own sentence because it is the version of this mistake that hides best. An async void method has no Task for anyone to observe, so an exception in it is rethrown on whatever context captured it and behaves like an unhandled exception rather than a faulted task. The documented advice on accessing HttpContext calls out async void explicitly as a thing to look for when a service produces sporadic null references.
The shape that works
The rule is to copy, not to capture. Read what you need from HttpContext synchronously while you are still inside the request — the user name, the tenant, the correlation identifier — into plain values, and pass those values onward. Never pass HttpContext itself, and never pass a scoped service.
For the dependency, inject IServiceScopeFactory, create a scope inside the background work, and resolve the DbContext from that scope so its lifetime matches the work rather than the request. That is the whole fix for the disposal half of the problem, and it is two lines.
For ownership, hand the work to something the host tracks. The conventional shape is a bounded Channel<T> written to by the request and read by a BackgroundService, which gives you a queue with a capacity you chose, a consumer whose failures you can log, and a CancellationToken from the host so shutdown is observed. The bound is the part people leave out and the part that matters, because an unbounded channel converts a traffic spike into heap growth.
When in-memory queuing is the wrong answer entirely
Be explicit about what a channel does not buy you. If the work must happen — an email that constitutes a legal notification, a payment capture, an event another system is waiting for — then holding it in the process's memory means a pod restart loses it, and pods restart on every deployment. That is a durability requirement, and it wants a row committed in the same transaction as the state change, picked up by a worker afterwards, or a message published to a broker with the outbox pattern making the publish atomic with the write.
Choosing between the two is the senior judgement in this question. A best-effort audit line or a cache warm-up is fine in a channel. Anything a user or another service depends on needs to survive the process, and a Task.Run in a controller is the furthest thing from that.
Work that outlives the response has outlived its scope and its HttpContext, so copy the values out while the request is alive, resolve dependencies from a scope you own, and give the task to something the host will still be holding when it fails.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).