📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are WeakReference and ConditionalWeakTable in .NET, and when are weak references the right tool?

Short and long weak references, WeakReference of T, ConditionalWeakTable for attached data, weak events, why weak caches are unreliable and their GC cost.

Senior Theory Advanced caching conditionalweaktable events gc weakreference

🎯 Key points

  • A weak reference refers to an object without keeping it alive
  • Short weak references clear when unreachable; long ones after finalisation
  • Use TryGetTarget and a local copy to avoid races
  • ConditionalWeakTable attaches values whose lifetime follows the key
  • Weak caches have unpredictable hit rates; prefer MemoryCache with limits
  • Each weak reference is a GC handle with scanning cost
  • Weak references are useful in tests to prove objects are released

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 with TryGetTarget into 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."


1. The kinds of weak reference

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

2. Using WeakReference<T> correctly

if (_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.

3. ConditionalWeakTable: attached data without leaks

Dictionary<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.

4. Weak events

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

5. Why weak caches disappoint

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.

6. Costs

  • Each weak reference is a GC handle the GC must check (and clear or keep) during collections; millions of them lengthen pauses.
  • 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.

7. Weak references in tests

[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.

Production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. How weak references work in the runtime

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:

  1. Marking runs from strong roots only; weak handles are not roots.
  2. After marking, the GC scans weak short handles: if the target wasn't marked, the handle is set to null.
  3. Finalisable unmarked objects are moved to the f-reachable queue (they become reachable again).
  4. The GC scans weak long handles: if the target is still unmarked, null it; if it was resurrected by finalisation, keep it.
  5. If the target survives and moves, the handle is updated to the new address.
strong roots --> A --> B         weak handle ---> C   (C unmarked)
                                    after GC: handle = null, C reclaimed

2. Dependent handles: how ConditionalWeakTable avoids cycles

A 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.

3. 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.

4. Short vs long in practice

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.

5. Observing handle usage

  • dotnet-dump analyze → gchandles -stat shows counts by handle type (Strong, Pinned, WeakShort, WeakLong, Dependent).
  • A steadily rising WeakShort or Dependent count suggests weak references or ConditionalWeakTable entries are created per request.
  • PerfView GC events include handle counts per GC on verbose traces.

6. Patterns and anti-patterns

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

7. Version notes

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

8. Try it yourself

  1. Run the code example: the dictionary keeps the rule alive, ConditionalWeakTable doesn't.
  2. Make the value hold a reference back to the key and confirm ConditionalWeakTable still allows collection while a Dictionary doesn't.
  3. Create 1 million WeakReference<object> instances and compare GC.GetTotalPauseDuration() with and without them across a few induced GCs.
  4. Build a weak cache with WeakReference<byte[]> for 10 KB arrays under allocation load and measure the hit rate.
  5. Write a test that proves an object subscribed to a static event is leaked, then fix it with unsubscription and watch the test pass.
  6. Take a dump and run gchandles -stat.

💻 Code

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);

⚠ Common mistakes

1. Using weak references as a general cache

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.

2. IsAlive then Target

What 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.

3. Attaching data to objects with a normal dictionary

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<...>>.

4. Expecting ConditionalWeakTable to use Equals

What 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.

5. Using weak events in services instead of unsubscribing

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.

6. Never pruning collections of weak references

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.

7. Creating weak references per request

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.

8. Using long weak references by default

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.

9. Forgetting the JIT can extend lifetimes in tests

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.

10. Using weak references to paper over ownership bugs

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions weak events Why: Checks event handler leaks
    How to answer

    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.

  2. When: Candidate uses weak references to find leaks Why: Checks leak investigation
    How to answer

    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.

  3. When: Candidate uses WeakReference to verify unloading Why: Checks collectible AssemblyLoadContext
    How to answer

    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.

🛠 Real-world problems: how you handle them

Rule reloads leaked compiled delegates through a static dictionary
Situation
A rules engine reloaded [thousands of] rules from configuration every [few minutes] and cached compiled delegates per rule.
Signals
Memory grew after each reload; heap snapshots showed old `Rule` objects rooted by a static dictionary.
Investigation
`gcroot` on old rules pointed to `Dictionary<Rule, Func<...>>` in a static cache.
Resolution
Replaced the dictionary with `ConditionalWeakTable<Rule, Func<...>>` so delegates live exactly as long as their rules.
Prevention
Added a test that reloads rules several times and checks old rules are collected via `WeakReference`.

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.

↩ Usually asked after

More in Garbage Collection

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me