Skip to content
Preptima
mediumCodingConceptMidSeniorStaff

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.

5 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

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

Further reading

aspnet-corehttpcontextbackground-workdependency-injectionasync