Home C# / .NET ObjectDisposedException in C#: Stop Using Dead Objects
Intermediate 5 min · September 23, 2026

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.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 15 min
  • Intermediate C# and async
  • How DI scopes and lifetimes work
  • Running dotnet test locally
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
✦ Definition~90s read
What is .NET ObjectDisposedException Fix?

ObjectDisposedException is the runtime telling you a resource's lifetime ended before its last use. Every disposable object pairs a cheap managed reference with expensive unmanaged resources — sockets, connections, handles, locks. Dispose returns the expensive half deterministically because the garbage collector only reclaims memory and never on a schedule you control.

Picture renting a power tool, returning it, then reaching for it in your garage.

The reference survives disposal by design (the GC still tracks it), so code holding it compiles, inspects, and then throws on first real use.

Three pairings dominate production incidents. HttpClient pairs a reusable socket pool with per-request disposal instincts — churning a pooled resource until ports run dry. DbContext pairs a scoped unit of work with async continuations that cross scope boundaries — executing queries against torn-down state.

Streams pair single-owner handles with helpful wrappers that claim disposal — closing borrowed resources out from under their owners. Each pairing fails the same way: a use lands after teardown, and the ObjectName property names which pairing broke.

The discipline that ends these incidents is lifetime-first thinking. Every disposable gets an explicit owner and an explicit span: factory-owned for HTTP, scope-bound for data access, single-owner for streams, per-test for fixtures. Use always precedes teardown because materialization, copying, and awaiting happen inside the span — never after.

Review code by tracing lifetimes across method boundaries, instrument the scarce resources (sockets, scopes, handles), and the entire exception class becomes rare enough to surprise you when it appears.

Plain-English First

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.

DisposeOrder.csCSHARP
1
2
3
4
5
6
7
8
9
await using var ctx = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var rows = await ctx.Orders.Where(o => o.IsStale).ToListAsync();
// use BEFORE the block ends:
foreach (var r in rows) r.Archived = true;
await ctx.SaveChangesAsync();
// BAD: return ctx.Orders.Where(...); // executes after disposal

// Single owner closes; wrappers leave the inner stream alone:
using var reader = new StreamReader(sharedStream, leaveOpen: true);
📊 Production Insight
The payments trace blamed the touching code, but the bug was the per-request using three frames up — disposal sites, not use sites, are where these bugs live.
🎯 Key Takeaway
Disposed means resources returned while the reference survives — move the use before teardown or extend the lifetime to cover it.

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.

HttpFactory.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
builder.Services.AddHttpClient<PaymentsClient>(c =>
{
    c.BaseAddress = new Uri("https://pay.example.com");
    c.Timeout = TimeSpan.FromSeconds(10);
});

public sealed class PaymentsClient(HttpClient http)
{
    public async Task<bool> ChargeAsync(string id, decimal amount)
    {
        // Never dispose the injected client: factory owns it.
        var res = await http.PostAsJsonAsync("/charge", new { id, amount });
        return res.IsSuccessStatusCode;
    }
}
📊 Production Insight
One registration line replaced per-request churn: 28,000 TIME_WAIT sockets collapsed to under 400 at higher traffic, and disposal errors hit zero overnight.
🎯 Key Takeaway
Inject factory-built clients, never construct or dispose per request — pool sockets with rotation, and alert on TIME_WAIT growth.

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.

ScopeAwait.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
public async Task<List<Order>> GetStaleAsync(IServiceProvider sp)
{
    using var scope = sp.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    return await db.Orders.Where(o => o.IsStale).ToListAsync();
} // materialized INSIDE: safe to return

// Background worker owns its scope:
await using var wscope = provider.CreateAsyncScope();
var wdb = wscope.ServiceProvider.GetRequiredService<AppDbContext>();
await wdb.Orders.Where(o => o.IsStale).ExecuteDeleteAsync();
📊 Production Insight
A fire-and-forget receipt task captured the request context and threw 40 minutes later — the trace blamed mail code while the lifetime bug hid in the capture.
🎯 Key Takeaway
Materialize inside the scope, return data never queries, and give background workers their own scope with a fresh context.

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.

StreamOwner.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static string ReadTwoWays(Stream shared)
{
    using var r1 = new StreamReader(shared, leaveOpen: true);
    string a = r1.ReadToEnd();
    shared.Position = 0;
    using var r2 = new StreamReader(shared, leaveOpen: true);
    string b = r2.ReadToEnd();
    return a + b; // caller still owns shared: only caller disposes
}

static async Task CopyAsync(string src, string dst)
{
    await using var inf = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read);
    await using var outf = new FileStream(dst, FileMode.Create);
    await inf.CopyToAsync(outf);
}
💡Default wrappers claim disposal — opt out
StreamReader and StreamWriter close the inner stream unless you pass leaveOpen: true. Helpers that wrap a borrowed stream must opt out, or the caller's next read hits a disposed corpse.
📊 Production Insight
A CSV helper wrapped the caller's upload stream in using and closed it; the subsequent virus-scan read threw disposed — one leaveOpen flag ended a week of finger-pointing.
🎯 Key Takeaway
One owner disposes last, borrowers use leaveOpen: true, and cross-thread handoffs complete before disposal.

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.

FixturePerTest.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public sealed class PricingTests : IAsyncLifetime
{
    private AppDbContext _db = null!;
    public Task InitializeAsync()
    {
        var opts = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
        _db = new AppDbContext(opts);
        return Task.CompletedTask;
    }
    public async Task DisposeAsync() => await _db.DisposeAsync();
    [Fact]
    public async Task Discount_applies()
    {
        _db.Orders.Add(new Order { Amount = 10m });
        await _db.SaveChangesAsync();
        Assert.Equal(1, await _db.Orders.CountAsync());
    }
}
📊 Production Insight
The suite passed per-test and failed in full runs — a static context disposed by an early test poisoned 14 later ones, found by bisection in under an hour.
🎯 Key Takeaway
Fresh fixture per test, disposed by its maker; shared factories are fine, shared live disposables are suite poison.

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.

DisposalRepro.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using Xunit;
public sealed class DisposalTests
{
    [Fact]
    public async Task Query_after_scope_throws_named_context()
    {
        IQueryable<Order> leaked;
        using (var scope = TestProvider.CreateScope())
        {
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            leaked = db.Orders.Where(o => o.IsStale); // not materialized!
        }
        await Assert.ThrowsAsync<ObjectDisposedException>(async () =>
            await leaked.ToListAsync());
    }
}
⚠ Never resurrect — reorder instead
There is no safe undispse: reusing a disposed object or suppressing the throw hides corruption. Always move the use before teardown or extend the lifetime — those are the only two correct fixes.
📊 Production Insight
Writing the scope-leak repro took 15 minutes and now guards every PR — any query crossing a scope boundary fails CI before it can reach production.
🎯 Key Takeaway
Name the corpse via ObjectName, find the disposal site, repro in miniature, reorder use-before-teardown, and guard the class forever.
● Production incidentPOST-MORTEMseverity: high

Per-Request HttpClient Took Down Payments at Noon

Symptom
Starting at 11:48 AM on a sale day, payment authorization failed on roughly 30% of attempts with a mix of ObjectDisposedException and socket-exhaustion timeouts. Failures scaled with traffic — clean at 200 rpm, catastrophic past 900 rpm. The service had deployed 3 days earlier with no errors at normal volume. Connection graphs showed 28,000 sockets in TIME_WAIT on a box configured for 16,000 ephemeral ports.
Assumption
The team blamed the payment provider's rate limits because failures scaled with traffic and the provider had throttled them once before. They cut traffic, retried with backoff, and opened a provider ticket — all useless. Load tests then showed the same failures against a stub endpoint, proving the bottleneck was local. The provider was never the problem; the client's own socket churn was.
Root cause
Each payment call constructed a new HttpClient in a using block and disposed it after one request. Disposal closes the socket but the OS holds the port in TIME_WAIT for 240 seconds, so 900 rpm piled up 28,000 dead sockets against 16,000 available ports. New connections then failed — sometimes as ObjectDisposedException when a raced handler touched a disposed client, sometimes as timeouts. The 3-day-old deploy had replaced a shared static client with per-request instances during a DI cleanup.
Fix
HttpClient creation moved to IHttpClientFactory with AddHttpClient registration and a 2-minute handler lifetime, restoring pooled sockets with periodic DNS refresh. Traffic at 1,200 rpm now holds under 400 sockets with zero disposal errors. A socket-count dashboard alert at 8,000 ports plus a load test at 2x peak were added so the next regression pages before customers notice.
Key lesson
  • 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.
Production debug guideFive checks that trace the corpse back to its disposal site.5 entries
Symptom · 01
Cannot access a disposed object. Object name: HttpClient
Fix
Search for new HttpClient in using blocks with grep -rn 'new HttpClient' --include='*.cs' — each per-request instance churns a socket. Confirm with netstat -an | find /c TIME_WAIT on Windows (28,000+ means exhaustion). Fix: register builder.Services.AddHttpClient<TClient>() and inject the client instead of constructing it.
Symptom · 02
Cannot access a disposed object. Object name: AppDbContext
Fix
Find queries returned past their scope — grep for methods returning IQueryable or Task without awaiting inside the using block. Verify with dotnet-trace collect -p <pid> showing disposal before the continuation. Fix: ToListAsync inside the scope and never capture the context in background lambdas.
Symptom · 03
Stream or reader throws after another component closed it
Fix
Audit ownership: grep -rn '\.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.
Symptom · 04
Failures scale with traffic but vanish at low volume
Fix
Plot sockets and exception rate against rpm — TIME_WAIT climbing linearly with traffic confirms churn. Run netstat -an | head during a load test. Fix: pool the underlying resource (factory, singleton, connection pool) instead of create-dispose per call.
Symptom · 05
Tests fail with disposed fixtures only in full-suite runs
Fix
Run dotnet test --filter FailingTest alone versus the suite — passing alone means cross-test disposal via shared static fixtures. Fix: build fixtures per test (IAsyncLifetime or fresh scope per test) and never share disposables across test classes.
ObjectDisposedException causes compared
Root CauseHow to ConfirmFixPrevention
HttpClient per-request churnTIME_WAIT in thousands; fails scale with rpmIHttpClientFactory or static singleton; never per-request disposeSocket-count alerts; 2x-peak load test on HTTP changes
Query past DbContext scopeUnmaterialized IQueryable crosses using boundaryToListAsync inside scope; fresh scope per background jobScoped registration; analyzer flags scope leaks
Shared stream closed by borrowerSecond consumer throws after helper disposesSingle owner; leaveOpen: true for wrappersDocument ownership; copy bytes instead of sharing
Test fixture shared across testsPasses alone, fails in suite; static disposable fieldFixture per test; factories shared, disposables notNo static disposables rule; bisection on suite-only red
Fire-and-forget captures scopeContinuation throws minutes after request endsIServiceScopeFactory scope inside workerBan uncaptured async lambdas holding scoped services
⚙ Quick Reference
6 commands from this guide
FileCommand / CodePurpose
DisposeOrder.csawait using var ctx = scope.ServiceProvider.GetRequiredService();What Disposed Really Means Under the Hood
HttpFactory.csbuilder.Services.AddHttpClient(c =>HttpClient
ScopeAwait.cspublic async Task> GetStaleAsync(IServiceProvider sp)DbContext in Async Code
StreamOwner.csstatic string ReadTwoWays(Stream shared)Streams and Readers
FixturePerTest.cspublic sealed class PricingTests : IAsyncLifetimeDisposed Fixtures in Tests
DisposalRepro.csusing Xunit;A Repeatable Disposal-Debugging Workflow

Key takeaways

1
Disposed means resources returned while the reference lives
reorder use before teardown.
2
Pool HttpClient via factory; never construct or dispose it per request.
3
Materialize queries inside the DbContext scope; return data, never IQueryable.
4
Give every stream one owner; borrowers pass leaveOpen
true and finish first.
5
Build test fixtures per test; share factories, never live disposables.
6
Give background workers their own scope instead of capturing the request's.

Common mistakes to avoid

6 patterns
×

Disposing HttpClient per request in a using block

Symptom
TIME_WAIT exhaustion past ~900 rpm; mixed disposed/timeout failures at peak only
Fix
Inject factory-built clients with AddHttpClient; let the factory own handler lifetimes
×

Returning IQueryable past a using boundary

Symptom
Disposed-context throw in innocent caller code far from the guilty return
Fix
Materialize with ToListAsync inside the scope; return data, never queries
×

Registering DbContext as Singleton

Symptom
Concurrency throws plus unbounded memory from leaked tracked entities
Fix
Register Scoped; resolve fresh scopes for background work via IServiceScopeFactory
×

Wrapping borrowed streams in disposing readers

Symptom
Caller's next read throws disposed right after your helper returns
Fix
Pass leaveOpen: true in wrappers; designate one owner that disposes last
×

Sharing one fixture across parallel tests

Symptom
Suite-only failures that pass solo; order-dependent red builds
Fix
Build and dispose fixtures per test; share only factories and config, never live objects
×

Fire-and-forget tasks capturing scoped services

Symptom
Throws minutes later in code the trace barely connects to the request
Fix
Create an explicit scope inside the worker and resolve fresh services there
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does ObjectDisposedException tell you?
Q02JUNIOR
Why can't you create HttpClient per request?
Q03SENIOR
Why do disposed-DbContext bugs survive code review?
Q04SENIOR
What does leaveOpen: true do and when do you need it?
Q05SENIOR
How do you diagnose suite-only disposal failures?
Q01 of 05JUNIOR

What does ObjectDisposedException tell you?

ANSWER
You used an object after Dispose returned its resources. The reference survives but the handles behind it are gone. Reorder so use precedes teardown, or extend the lifetime to cover the use.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Should I ever catch ObjectDisposedException?
02
Is a static HttpClient still acceptable?
03
Why does the trace blame innocent code?
04
Can finalizers save me from missing Dispose?
05
How do I prove a scope leak in a test?
06
Do I need IHttpClientFactory for a nightly job?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Drawn from code that ran under real load.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Exceptions. Mark it forged?

5 min read · try the examples if you haven't

Previous
.NET OutOfMemoryException Fix
5 / 5 · Exceptions