Short and long weak references, WeakReference of T, ConditionalWeakTable for attached data, weak events, why weak caches are unreliable and their GC cost.
A weak reference lets you refer to an object without keeping it alive: if only weak references remain, the GC can collect it, and the reference then returns null. `WeakReference<T>` uses TryGetTarget; a short weak reference is cleared before finalisation, a long one (trackResurrection: true) only after. `ConditionalWeakTable<TKey,TValue>` attaches data to an object that lives exactly as long as the key, without leaking. Good uses: attached metadata, weak event subscriptions, and caches of expensive objects that are fine to lose. Weak references make poor general caches, because entries can disappear at the next GC, and every weak reference is a GC handle with a cost.
"A weak reference lets you point at an object without keeping it alive. If the only remaining references are weak, the GC can collect the object, and the weak reference then gives you nothing back.
WeakReference<T>is the main API; you read it withTryGetTargetinto a local. Short weak references are cleared as soon as the object is unreachable; long ones (trackResurrection: true) are cleared only after finalisation.ConditionalWeakTable<TKey, TValue>is the more useful tool day to day: it attaches a value to an object so the value lives exactly as long as the key, without the leak a normal dictionary would cause. Weak references are the right tool for lifetime problems: attached metadata, some event patterns, and tests that prove something was released. They're the wrong tool for caching, because entries can vanish at the next GC, and each one is a GC handle with a cost."
| Type | Cleared when | Typical use |
|---|---|---|
WeakReference<T> (short, default) |
Object becomes unreachable (before finalisers run) | Most uses |
WeakReference(obj, trackResurrection: true) (long) |
After the object's finaliser has run and it's still unreachable | Rare: objects that may resurrect |
ConditionalWeakTable<TKey,TValue> |
Key collected → entry and value released | Attaching state to objects you don't own |
DependentHandle (public since .NET 6) |
Primary collected | Building custom weak structures |
WeakReference<T> correctlyif (_weak.TryGetTarget(out var report))
Render(report); // 'report' is a strong local: safe for this block
else
_weak.SetTarget(report = Build());
IsAlive followed by Target is a race: the object can be collected between the two calls.
ConditionalWeakTable: attached data without leaksDictionary<object, T> |
ConditionalWeakTable<object, T> |
|
|---|---|---|
| Keeps keys alive | Yes (leak) | No |
| Value lifetime | Until removed | Same as the key |
| Value referencing its key | Keeps both alive forever | Still collectable (no cycle leak) |
| Key comparison | Equals/GetHashCode |
Reference identity |
| Thread safety | No (needs locking) | Yes |
Common uses: caching compiled delegates/metadata per object, attaching state to third-party objects, per-instance data for source-generated or library code.
A long-lived publisher (static event, singleton) holds strong references to subscribers through delegates, so short-lived subscribers never die. Options:
| Option | When |
|---|---|
Unsubscribe in Dispose |
Default in services: explicit and clear |
IObservable<T> with disposable subscriptions |
Reactive pipelines |
Weak event pattern (e.g. WPF WeakEventManager) |
UI frameworks with many short-lived views |
| Expectation | Reality |
|---|---|
| "Entries stay until memory is tight" | They're collected at the next GC of their generation, often milliseconds for young objects |
| "Hit rate will be good" | Unpredictable and load-dependent |
| "It saves memory" | Each entry costs a GC handle plus the WeakReference object |
For caching, use MemoryCache with SizeLimit, expiry and eviction, which gives predictable behaviour.
WeakReference objects themselves are heap objects; a List<WeakReference<T>> that is never pruned is its own leak.ConditionalWeakTable entries use dependent handles, which are more expensive than plain weak handles.[Fact]
public void Plugin_is_released_after_unload()
{
WeakReference weak = LoadAndUnload(); // helper must be NoInlining
for (int i = 0; weak.IsAlive && i < 10; i++) { GC.Collect(); GC.WaitForPendingFinalizers(); }
Assert.False(weak.IsAlive);
}
This is the standard way to prove a view, plugin, AssemblyLoadContext or DbContext is not leaked.
Our [rules engine] reloaded [thousands of] rules from configuration every [few minutes] and cached a compiled Func<Context, bool> per rule in a static Dictionary<Rule, Func<...>>. Memory climbed after every reload. In a dump, gcroot on old Rule instances pointed at that static dictionary: it kept every rule ever loaded alive, together with its expression trees and delegates. I replaced it with ConditionalWeakTable<Rule, Func<...>> using GetValue(rule, Compile), so each delegate lives exactly as long as its rule. Memory went flat across reloads, and I added a test that reloads several times and asserts the old rules are collected using WeakReference.
The interviewer checks nuanced understanding of object lifetimes.
Green flags: short versus long, TryGetTarget, ConditionalWeakTable semantics and use, weak events, why weak caches are poor, GC handle cost, test usage.
Red flags: proposes weak references as a memory-saving cache; uses IsAlive then Target; unaware of ConditionalWeakTable.
new WeakReference<T>(obj) allocates a small managed object that owns a GC handle of type weak short (or weak long with trackResurrection). During a GC:
strong roots --> A --> B weak handle ---> C (C unmarked)
after GC: handle = null, C reclaimed
ConditionalWeakTable avoids cyclesA dependent handle says: "keep secondary alive only while primary is alive", without making primary reachable from it. ConditionalWeakTable stores each entry as a dependent handle (key = primary, value = secondary).
key (primary) --dependent--> value (secondary)
value --> key // even this back-reference doesn't keep the key alive
With a normal dictionary, table -> value -> key would root the key forever. With a dependent handle, the GC marks the value only if the key is already marked through some other path.
ConditionalWeakTable API| Member | Notes |
|---|---|
GetValue(key, createValueCallback) |
Thread-safe get-or-add; the callback may run more than once under races, only one result is kept |
GetOrCreateValue(key) |
Uses TValue's parameterless constructor |
TryGetValue, Add, AddOrUpdate, TryAdd, Remove, Clear |
Mutations (exact availability varies by version) |
| Enumeration | Supported in .NET Core 2.0+, returns a snapshot of live entries |
Keys are compared by reference identity, so two equal records are different keys.
| Scenario | Short | Long |
|---|---|---|
| Object with no finaliser | Cleared when unreachable | Same as short |
| Object with a finaliser | Cleared before finaliser runs | Stays set until after finalisation; may observe a finalised object |
Long weak references let you observe objects in a "finalised but not yet reclaimed" state, which is rarely what you want.
dotnet-dump analyze → gchandles -stat shows counts by handle type (Strong, Pinned, WeakShort, WeakLong, Dependent).ConditionalWeakTable entries are created per request.| Pattern | Verdict |
|---|---|
ConditionalWeakTable<object, Metadata> for per-object caches |
Good |
Test helper using WeakReference + forced GCs to detect leaks |
Good |
Dictionary<string, WeakReference<Data>> as an app cache |
Poor: unpredictable hits, dead entries accumulate |
List<WeakReference<Handler>> for events without pruning |
Leaks the WeakReference objects |
| Weak references to avoid fixing an ownership bug | Hides the bug |
| Version | Change |
|---|---|
| .NET Framework 4.0 | ConditionalWeakTable |
| .NET Framework 4.5 | WeakReference<T> |
| .NET Core 2.0 | ConditionalWeakTable enumeration, AddOrUpdate, Clear |
| .NET 6 | DependentHandle public |
| .NET 8–10 | No semantic changes |
ConditionalWeakTable doesn't.ConditionalWeakTable still allows collection while a Dictionary doesn't.WeakReference<object> instances and compare GC.GetTotalPauseDuration() with and without them across a few induced GCs.WeakReference<byte[]> for 10 KB arrays under allocation load and measure the hit rate.gchandles -stat.using System.Runtime.CompilerServices;
var strongMap = new Dictionary<Rule, string>();
var weakMap = new ConditionalWeakTable<Rule, string>();
WeakReference<Rule> viaDictionary = Attach(strongMap, weakMap, useWeak: false);
WeakReference<Rule> viaWeakTable = Attach(strongMap, weakMap, useWeak: true);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine($"Rule kept alive by Dictionary: {viaDictionary.TryGetTarget(out _)}"); // True: leak
Console.WriteLine($"Rule kept alive by ConditionalWeakTable: {viaWeakTable.TryGetTarget(out _)}"); // False
// Correct use of WeakReference<T>: copy the target to a local
var cacheEntry = new WeakReference<byte[]>(new byte[1024]);
if (cacheEntry.TryGetTarget(out byte[]? data))
Console.WriteLine($"Still cached: {data.Length} bytes");
else
Console.WriteLine("Collected; recompute");
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference<Rule> Attach(Dictionary<Rule, string> strong, ConditionalWeakTable<Rule, string> weak, bool useWeak)
{
var rule = new Rule($"rule-{useWeak}");
if (useWeak) weak.Add(rule, "compiled form");
else strong[rule] = "compiled form";
return new WeakReference<Rule>(rule);
}
sealed record Rule(string Name);
What happens: Dictionary<string, WeakReference<T>> to "cache without using memory".
Why it is wrong: entries disappear at the next GC; hit rates are random; dead entries pile up.
Do instead: MemoryCache with size limits, expiry and eviction.
IsAlive then TargetWhat happens: if (wr.IsAlive) Use(wr.Target);.
Why it is wrong: the object can be collected between the calls, giving a NullReferenceException.
Do instead: TryGetTarget(out var t) and use the local.
What happens: static Dictionary<Rule, Func<...>> keyed by objects that are replaced over time.
Why it is wrong: the dictionary keeps every key (and its graph) alive forever.
Do instead: ConditionalWeakTable<Rule, Func<...>>.
ConditionalWeakTable to use EqualsWhat happens: records or strings used as keys, expecting value equality. Why it is wrong: keys are compared by reference identity. Do instead: use it only to attach data to specific instances.
What happens: a custom weak event system is built to avoid Dispose.
Why it is wrong: added complexity; handlers may run on half-dead objects until the next GC.
Do instead: unsubscribe deterministically in Dispose.
What happens: a List<WeakReference<T>> grows with dead entries.
Why it is wrong: the WeakReference objects and their handles leak.
Do instead: prune dead entries periodically or use ConditionalWeakTable.
What happens: every request allocates WeakReferences.
Why it is wrong: GC handle churn and scanning cost add to pauses.
Do instead: reserve weak references for long-lived, low-volume uses.
What happens: trackResurrection: true "to be safe".
Why it is wrong: you may observe finalised objects in an invalid state.
Do instead: use short weak references unless you specifically need resurrection tracking.
What happens: a leak test creates the object in the same method and asserts it's collected; it fails in Debug.
Why it is wrong: locals can stay alive until the method ends (especially in Debug builds).
Do instead: create the object in a [MethodImpl(MethodImplOptions.NoInlining)] helper and return only the WeakReference.
What happens: a leak is "fixed" by making references weak. Why it is wrong: the real owner/lifetime problem remains and causes non-deterministic behaviour. Do instead: fix ownership: dispose, unsubscribe, scope correctly.
Weak references are a precise tool for lifetime problems, not a performance trick.
| Problem | Weak tool fits? |
|---|---|
| Metadata attached to third-party objects | Yes: ConditionalWeakTable |
| Long-lived publisher, short-lived subscribers | Sometimes: weak events, but prefer explicit unsubscription |
| Cache to reduce latency | No: use a bounded cache |
| Verifying an object was collected in a test | Yes: WeakReference plus forced GC in tests |
Seniors also use weak references in tests to prove that plugins, views or contexts are released.
The convention is EventHandler<TEventArgs> with object? sender and an immutable args object, EventArgs.Empty when there is no data, and a protected virtual OnXxx method that raises it. The big production risk is leaks: the publisher's delegate holds a strong reference to every subscriber, so a singleton or static publisher keeps short-lived subscribers alive forever. I find them with a heap dump and gcroot, which shows the path through the event's delegate, and fix them by unsubscribing in Dispose or using a weak event. Async handlers are async void, so I keep them thin and catch everything inside.
First confirm a leak: memory keeps rising across GCs under steady load, rather than a cache warming up. Then decide managed or native: if the GC heap size (dotnet-counters) grows with the working set, it is managed; if the working set grows while the GC heap is flat, look at native memory. For managed leaks, take two dotnet-gcdump snapshots some time apart and compare which types grow, then use dotnet-dump with dumpheap -stat and gcroot to find what keeps them alive. Typical roots: static collections and caches, event subscriptions, timers, captured closures, DI root-scoped transients, and unbounded queues. Fix, then verify with the same metrics under load.
AssemblyLoadContext (ALC) is the modern replacement for AppDomains as the unit of assembly isolation. Each ALC has its own set of loaded assemblies, so plugins can bring their own dependency versions. A custom ALC overrides Load and uses AssemblyDependencyResolver to resolve from the plugin's deps.json. Shared contract assemblies must come from the default context (return null from Load), otherwise the same interface loaded twice is two different types and casts fail. With isCollectible: true you can call Unload(), but unloading is cooperative: it completes only when nothing references the plugin's types, objects, delegates or threads. Verify it with a WeakReference to the ALC.
How to tell it (STAR)
In [our rules engine], each reload left old rules alive because a static dictionary held them. I switched to ConditionalWeakTable so cached delegates lived only as long as their rule, and added a WeakReference-based test for reloads.
Last updated 31 Oct 2026.