You kick off background work from a controller action and it throws once the response has gone out. What is happening?
The request scope is torn down when the response completes, so a detached task finds IHttpContextAccessor.HttpContext null and its injected scoped services disposed. Copy the values you need, resolve a fresh scope, and hand the work to something the host actually tracks.
What the interviewer is scoring
- Whether the failure is tied to the request scope being disposed when the response completes
- Does the candidate separate a null HttpContext from the accessor from an ObjectDisposedException on a scoped service
- That async-local flow is named as the reason the accessor works during the request and not after it
- Whether they notice the host is not tracking the detached task, so a shutdown drops it silently
- Does the answer copy values and resolve a new scope rather than reusing the injected dependency
Answer
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();
// Fire and forget: the action returns immediately, the response completes,
// and the request scope that owns _db is disposed underneath this task.
_ = Task.Run(async () =>
{
var user = _accessor.HttpContext!.User.Identity!.Name; // HttpContext is null here
_db.AuditEntries.Add(new AuditEntry(user)); // _db is already disposed
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.
Likely follow-ups
- Where does a DbContext for work that outlives the request come from, and who disposes it?
- The pod is drained mid-deployment. What happens to work already queued in memory, and what would you change to survive that?
- What does async void do with an exception, and where does that exception surface?
- How do you carry the correlation identifier into the background work once the accessor returns null?
Related questions
- A client disconnects but your server keeps working on their request. How does cancellation actually propagate in .NET?mediumAlso on async and aspnet-core4 min
- A promise rejects and nothing is awaiting it. What does Node do?hardAlso on async4 min
- You await Task.WhenAll over three calls and two of them fail. What does your catch block see?hardAlso on async4 min
- Messages arrive with a type field and each type needs a different object built to process it. How do you design the creation so you do not end up with a growing switch statement?mediumAlso on dependency-injection5 min
- Angular, Vue and React are answers to the same problems. Compare how each handles change detection, reactivity and dependency injection.hardAlso on dependency-injection5 min
- Walk me through the phases of the Node event loop.mediumAlso on async4 min
- Go gives you no project layout and no dependency-injection container. How do you structure a service so it stays testable as it grows?mediumAlso on dependency-injection5 min
- Why does calling .Result or .Wait() on an async method deadlock, and how do you fix it properly?hardAlso on aspnet-core5 min