ObjectDisposedException in C#: Stop Using Dead Objects
Keep one HttpClient alive via factory or singleton, await work inside using blocks, and never share DbContext across threads.
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
- ✓Intermediate C# and async
- ✓How DI scopes and lifetimes work
- ✓Running dotnet test locally
- ObjectDisposedException means you touched an object after Dispose tore it down — the handle, connection, or socket is gone
- Never create HttpClient per request; use IHttpClientFactory or a single static instance so sockets survive
- Await every query inside the DbContext scope — returning queries past the using boundary kills them on arrival
- Don't share contexts or streams across threads; resolve a fresh scope per background job with IServiceScopeFactory
- In tests, build a new fixture per test instead of reusing disposed ones — disposed-after failures are test bugs, not app bugs
Picture renting a power tool, returning it, then reaching for it in your garage. It's gone — you gave it back. That's ObjectDisposedException: code holds a reference it already threw away — a connection closed by its using block, a client disposed after one call, a stream shut while another thread reads. Finish work before checkout, or stop checking out things you still need.
ObjectDisposedException is the lifetime bug that survives every code review because each method looks correct in isolation. The using block is textbook. The async call is textbook. The factory registration is textbook. But composed across scopes and threads, the disposal lands before the last use — a query returned past its scope, a client disposed per request, a context shared with a background task that outlives the request.
HttpClient is the famous case: disposing per request churns sockets into TIME_WAIT until the box runs dry, yet never disposing a DNS-cached singleton misses DNS changes. DbContext is the common case: scoped per request, dead the moment the scope ends, and lethal in any fire-and-forget continuation. Streams and readers fill out the trio — closed by one owner while another still reads.
This guide maps each pattern to its fix: IHttpClientFactory for HTTP, scope-bound awaiting for contexts, single ownership for streams, and fixture-per-test discipline. You'll learn to read the ObjectName property (it names the corpse), trace disposal to its scope, and restructure so use always precedes teardown.
What Disposed Really Means Under the Hood
Dispose tears down the unmanaged half of an object — OS handles, sockets, connections, file locks — while leaving the managed reference intact. That's why the failure is confusing: your variable isn't null, the debugger shows its fields, but every method throws because the resources behind it are gone. The ObjectName property names the corpse (HttpClient, AppDbContext, FileStream), and the stack trace shows who touched it — but neither shows who disposed it, which is the actual bug.
IDisposable exists because garbage collection only reclaims memory. Sockets, handles, and connections are scarce OS resources the GC can't see, so Dispose returns them deterministically instead of waiting for finalization. Using blocks automate the call on every exit path. The contract is one-way: once disposed, an object stays disposed — there is no undispse, and any use after is a defect by definition.
This asymmetry shapes every fix. You can't resurrect the object; you reorder the program so use precedes teardown. Find the disposal site (the using boundary, the scope end, the explicit Dispose call), find the late use (the continuation, the background task, the second test), and move one of them. Either extend the lifetime to cover the use, or finish the use before the lifetime ends. No third option exists, which makes these bugs wonderfully mechanical once you see them clearly.
HttpClient: The Singleton That Must Not Die Per Request
HttpClient looks disposable, so developers dispose it — and that instinct causes outages. Each disposal abandons its socket to TIME_WAIT for up to 4 minutes. At 900 requests per minute, dead sockets accumulate 28,000 deep against 16,000 ports, and new connections fail. The exception varies (disposed-object, timeout, connection-refused) but the cause is one: churning a pooled resource as if it were single-use.
The fix is sharing with rotation. IHttpClientFactory pools HttpMessageHandler instances with a default 2-minute lifetime: sockets reuse aggressively while DNS refreshes periodically, dodging both exhaustion and stale-DNS traps of static singletons. Registration is one line (AddHttpClient), consumption is constructor injection, and typed clients keep configuration per downstream service. For legacy code, a single static HttpClient works but needs handler-lifetime management by hand.
Watch the two adjacent traps. Disposing the injected client from the factory breaks pooling — the factory owns the lifetime, not you. And socket metrics belong on every dashboard: TIME_WAIT counts predict exhaustion hours early. After the incident, the team's load test at 2x peak plus an 8,000-port alert made this class self-announcing. No HTTP plumbing change ships without surviving the peak it will serve.
DbContext in Async Code: Await Inside the Scope
DbContext dies with its scope, and async continuations routinely outlive theirs. The pattern that kills: a method returns an unmaterialized IQueryable or an un-awaited Task, the using block exits, disposal runs, and the caller's await executes the query against a corpse. The trace points at the innocent await line in the caller while the guilty return sits a frame away — which is why these bugs survive reviews that examine each method alone.
The rule is absolute: materialize before the boundary. Call ToListAsync, FirstOrDefaultAsync, or SaveChangesAsync inside the using block and return data, never queries. For background work, create a dedicated scope with IServiceScopeFactory inside the worker and resolve a fresh context there — never capture the request's context in a lambda that runs after the response.
Registration discipline backs it up. DbContext must be Scoped: Singleton shares one context across threads (concurrency throws plus memory leaks), Transient hides lifetime bugs until scale exposes them. Tests need the same care — an in-memory context per test, disposed by the test that built it. Scope-bound awaiting plus scoped registration removes the entire category: no query can outlive its context because none ever crosses the boundary unmaterialized.
Streams and Readers: One Owner Closes
Shared streams die the same death: a StreamReader closes its underlying stream on dispose by default, so the second consumer finds a corpse. Helpers that accept a stream and wrap it in using look tidy and destroy the caller's resource. The fix is explicit single ownership — exactly one component disposes, everyone else borrows with leaveOpen: true and documents the arrangement in comments.
The leaveOpen parameter exists for this: new StreamReader(stream, leaveOpen: true) lets wrappers read without claiming disposal rights. The owner — usually the method that opened the file or the object holding the field — disposes last, after all borrowers finish and return. For async pipelines, await using on the owner guarantees flushed bytes before the handle closes.
Cross-thread sharing adds ordering to ownership. A stream closed on thread A while thread B reads throws regardless of flags, so hand off with explicit completion — await the reader task before disposing, or transfer ownership with a clear comment. When in doubt, don't share: copy the bytes into memory once and hand each consumer its own MemoryStream. Memory is cheap; debugging cross-thread disposal races at midnight is not worth it.
Disposed Fixtures in Tests: Stop Sharing Corpses
Test suites produce the same exception for dumber reasons: a fixture disposed by test A gets reused by test B. Static shared contexts, ClassFixtures holding one DbContext for fifty tests, and parallel collections racing disposal all create failures that pass in isolation and fail in the suite — the classic suite-only red build that wastes whole mornings.
The fix is fixture-per-test economics. Build a fresh in-memory context or container per test, dispose it in the same test, and accept the milliseconds of setup cost — it's cheaper than one flaky-suite investigation. xUnit's IAsyncLifetime gives per-test Initialize/DisposeAsync hooks; collection fixtures should hold factories and connection strings, never live disposables.
Diagnose by bisection: run the failing test alone (green means cross-test interference), then binary-search the suite halves until the killer pair surfaces. Parallelization settings deserve a look too — tests sharing a static resource must declare a collection boundary or stop sharing. The incident team added a simple rule: no static disposable fields in test projects, enforced by a 10-line analyzer. Suite-only disposal failures dropped to zero within a sprint and never returned.
A Repeatable Disposal-Debugging Workflow
Start with the ObjectName — it tells you what died. HttpClient means pooling, DbContext means scope, Stream means ownership, fixture types mean test sharing. Then find the disposal site, not the use site: search for the using boundary, scope end, or Dispose call governing that instance. The use site in the trace is the victim; the disposal site upstream is the criminal.
Reproduce with lifetime in miniature: construct, dispose, then use — assert it throws the same ObjectName. For async scope bugs, write the test with an explicit scope block and an await after it; for socket churn, loop 1,000 requests and watch TIME_WAIT climb. Minimal repros turn cross-method mysteries into single-screen obviousness anyone can see.
Fix by reordering, never by resurrecting. Extend the lifetime (wider scope, factory pooling, single ownership) or finish the work earlier (materialize, copy bytes, await inside). Then add the guard that announces the next one: socket-count alerts, scope-leak analyzers, parallel-open tests, fixture rules. Disposal bugs are mechanical — use before teardown, always — and a codebase with the right guards stops producing them entirely within a single quarter of steady focused work.
Per-Request HttpClient Took Down Payments at Noon
- Never dispose HttpClient per request — pool via IHttpClientFactory or a static singleton so sockets survive.
- Load-test at 2x peak after any HTTP plumbing change; volume bugs hide at normal traffic for days.
- Alert on socket counts, not just error rates — TIME_WAIT growth predicts the outage hours before failures start.
Dispose()\|\.Close()' near shared streams and check which owner closes first. Reproduce with two sequential users of one stream. Fix: assign a single owner (leaveOpen: true for wrappers like StreamReader over shared streams) and document who disposes.| File | Command / Code | Purpose |
|---|---|---|
| DisposeOrder.cs | await using var ctx = scope.ServiceProvider.GetRequiredService | What Disposed Really Means Under the Hood |
| HttpFactory.cs | builder.Services.AddHttpClient | HttpClient |
| ScopeAwait.cs | public async Task
| DbContext in Async Code |
| StreamOwner.cs | static string ReadTwoWays(Stream shared) | Streams and Readers |
| FixturePerTest.cs | public sealed class PricingTests : IAsyncLifetime | Disposed Fixtures in Tests |
| DisposalRepro.cs | using Xunit; | A Repeatable Disposal-Debugging Workflow |
Key takeaways
Common mistakes to avoid
6 patternsDisposing HttpClient per request in a using block
Returning IQueryable past a using boundary
Registering DbContext as Singleton
Wrapping borrowed streams in disposing readers
Sharing one fixture across parallel tests
Fire-and-forget tasks capturing scoped services
Interview Questions on This Topic
What does ObjectDisposedException tell you?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.
That's Exceptions. Mark it forged?
5 min read · try the examples if you haven't