A weak reference lets you hold a reference to an object without preventing the garbage collector from reclaiming it. The usual reference you write, Product p = new Product(...), is a strong reference, and as long as it's reachable from the root set, the GC won't collect the object behind it. There are cases where that's the wrong behavior: a cache that should let entries go when memory gets tight, an event subscription that shouldn't keep a long-dead subscriber alive, a sidecar dictionary of debug metadata that shouldn't extend the lifetime of the objects it describes. This lesson covers WeakReference<T>, its non-generic cousin, and ConditionalWeakTable<TKey, TValue>.
Every variable that holds an object is a strong reference. Fields, locals, statics, array slots, dictionary entries, captured variables in closures: if any one of them can be reached from a root (a static field, a live local on a thread's stack, a GC handle), the object behind it is alive. The GC walks the graph from the roots and collects only what it can't reach.
A long-lived cache is the classic place this bites. Consider a "recently viewed products" cache on an e-commerce site: a Dictionary<int, Product> keyed by product id. Every product the user looks at gets dropped in. Over the lifetime of the application, that dictionary grows without bound, and every Product it contains stays alive because the dictionary holds a strong reference to it. The cache, designed to be helpful, has become a memory leak.
The GC.Collect() call didn't reclaim anything because the dictionary's strong references keep every Product rooted. That's correct behavior for a regular cache, but it's not what you want for "recently viewed" if the goal is "remember these only while they're already in memory for another reason."
Every strong reference, no matter how trivial it looks, extends an object's lifetime to the lifetime of the strong reference's owner. A static List<Product> you forgot about will hold every product you ever added until the process exits.
A weak reference flips that. The reference itself is real, but the GC ignores it when deciding what's reachable. If a Product has only weak references pointing at it, the next GC is free to collect it. If a strong reference still exists elsewhere, the product stays alive and the weak reference still works.
Visualizing reachability makes the difference concrete. In the first state, a Product is held by both a strong root and a weak reference. In the second state, the strong root has dropped, leaving only the weak reference, and the GC reclaims the object.
The dashed arrows are weak references. The GC's reachability analysis ignores them entirely. In the first state, the object is reachable because of the strong root, so the weak reference happens to work too. In the second state, the strong root is gone, the object is unreachable, and a future GC will collect it. After collection, asking the weak reference for its target gives back nothing.
A weak reference is a way to ask "is this object still alive somewhere else?" without being one of the reasons it's alive.
WeakReference<T>: The Generic FormModern C# uses WeakReference<T> for typed weak references. It's a small wrapper class that holds the reference weakly and exposes a TryGetTarget method to retrieve it.
TryGetTarget returns true and assigns the live object to the out parameter when the target is still alive, and returns false (with the out parameter set to null) when it isn't. That call also briefly promotes the target to a strong reference for the duration of your code that runs against it: once you have alive in hand, the GC can't reclaim it, because your local is a strong reference.
The mirror operation is SetTarget, which replaces what the weak reference points at:
SetTarget is rarely needed in everyday code. It's there for object-pool-style scenarios where one weak reference cell is reused to track different objects over time.
TryGetTarget is a quick handle-table lookup, not free but cheap (single-digit nanoseconds on modern hardware). Calling it in a hot path on millions of objects is fine; calling it on every access of a per-element cache value isn't dramatically different from a dictionary lookup.
WeakReference and Why TryGetTarget WinsThe original .NET 1.0 type, WeakReference (no generic parameter), is still around for compatibility. It exposes a Target property of type object? and an IsAlive boolean:
Two reasons to prefer the generic WeakReference<T> over this older type. The first is type safety: WeakReference.Target is object?, so every read needs a cast. The second is the race condition that the older API quietly allows. Consider this pattern:
Between the IsAlive check and the Target read, the GC can run on another thread and collect the target. IsAlive returns true, the object is collected, then Target returns null, and the cast or dereference crashes. The same race exists even if you skip IsAlive and read Target twice, because each read can return a different value.
TryGetTarget closes the window. It atomically reads the weak reference and, if the target is alive, copies it into your out parameter internally as a strong reference. Once you have the local, the GC can't reclaim it. The non-generic API has no equivalent, which is why every new piece of code should use WeakReference<T>.
Treat the non-generic WeakReference and its IsAlive/Target pair as a legacy code shape: recognize it when you see it, replace it when you can.
Both WeakReference and WeakReference<T> have a constructor that takes a trackResurrection flag (also called trackResurrection in WeakReference<T>). The flag controls when the weak reference stops working relative to the finalizer machinery.
trackResurrection: false, the default) stops returning the target as soon as the GC has determined the object is unreachable, even if the object's finalizer hasn't yet run. This is what almost every cache wants: by the time the cache notices the object is "gone," it really is gone for practical purposes.trackResurrection: true) keeps returning the target through finalization. If the finalizer resurrects the object by reattaching it to a static field, the long weak reference will still find it. This is rare, and most C# developers go a whole career without writing one.Output (order of finalizer messages may vary):
In this example both targets are gone by the time the second Collect finishes because neither finalizer resurrects them. The distinction only matters when a finalizer does resurrect, and that's both unusual and a code smell (the _Finalizers (Destructors)_ lesson covers why resurrection is something to avoid). For 99% of weak-reference uses, the default short form is appropriate; calling the constructor without the flag is fine.
Short and long weak references are both backed by GC handles in the same handle table. The cost difference between them is negligible. Choose based on semantics, not performance.
The simplest concrete payoff is a cache that lets entries go when nothing else is holding the cached object. A "recently viewed products" panel is a natural fit: if the user opened a product page, the Product is likely held by the page's state, and the cache needs a way to find it again if some other code asks for it.
The cache itself only holds WeakReference<Product> instances, not the products. As long as some other part of the app holds a strong reference (the page, a controller, a list of currently-viewed items), the cache works. The moment the last strong reference goes, the GC is free to collect, and the cache reports (gone) cleanly.
The _entries.Remove(id) line on a miss is a small piece of housekeeping: dead weak references still occupy a dictionary slot, so trimming them on access prevents the dictionary itself from growing without bound. A production cache would also run a periodic sweep to remove dead entries that nobody asks about.
A weak cache is not a replacement for a real cache library. WeakReference<T> gives you GC-driven eviction, which is unpredictable: a Gen 0 collection might happen seconds after the strong reference drops, or minutes later, or never if the process never feels memory pressure. If you need predictable LRU eviction, size limits, or expiration policies, use Microsoft.Extensions.Caching.Memory.MemoryCache or a similar library. Use weak references when "let the GC decide" is the policy you want.
Events are the second classic source of leaks. A publisher's event holds a delegate, and the delegate holds a reference to the subscriber's instance. Subscribe a short-lived WishlistView to a long-lived WishlistStore.ItemAdded event, forget to unsubscribe, and the WishlistView lives as long as the store does. Every closed view is still in memory, still receiving events, still doing work.
The conventional fix is to unsubscribe explicitly (often through IDisposable). When you can't (the subscriber doesn't know when it'll be discarded, or the subscription is buried in a library), a weak-subscriber pattern lets the publisher hold the subscriber weakly.
The store holds a WeakReference<Action<string>> per subscriber. While handler is alive in the caller, the delegate stays rooted and the event fires normally. The moment the caller drops both view and handler, the delegate (which captures view) becomes collectible. The next event invocation finds the weak reference's target gone and prunes it from the list.
The pattern has a subtlety: the publisher must hold the delegate weakly, not just the subscriber instance. A delegate is its own reference-typed object on the heap, and it strongly references the target (view in this case) through its Target property. If you store the delegate strongly, the subscriber lives. The example sidesteps this by storing a WeakReference<Action<string>> of the delegate itself, which means the caller has to keep its own strong reference (the handler local) until they really want the subscription to end. Production weak-event implementations (the WPF WeakEventManager is the canonical example) split the delegate into a weak reference to the target and a normal reference to the method, so the caller doesn't have to manage a strong delegate explicitly. Building that correctly is beyond this lesson, but the principle is the same: every reference the publisher holds toward the subscriber chain has to be weak for the pattern to let the subscriber go.
Walking the listener list on every event is O(n) in the number of subscribers, the same as a normal multicast delegate would be. The added cost is one TryGetTarget per subscriber per event. For UI-rate events (clicks, scroll), that's invisible; for events firing thousands of times per second, prefer explicit unsubscription.
ConditionalWeakTable<TKey, TValue>The third common use case is attaching extra data to an object without modifying its class and without extending its lifetime. Consider a Product class from a library you don't control, where you want to track a transient "view count" or "last hover time" per product instance. A Dictionary<Product, int> works, but it's a memory leak: the dictionary's strong reference to each Product key keeps every product you ever saw alive forever.
ConditionalWeakTable<TKey, TValue> from System.Runtime.CompilerServices is the fix. Both TKey and TValue must be reference types. The table holds keys weakly: when the key has no strong references anywhere else, the table drops both the key and the value automatically. The value is held as long as the key is reachable, but the table never extends the key's lifetime.
The mouse's entry disappears from the table once mouse is set to null and a GC runs. The keyboard's entry is untouched because keyboard is still rooted. There's no need for any explicit cleanup code; the table prunes itself based on key reachability.
API points to know:
GetValue(key, valueFactory) returns the existing value if present, or creates a new one with the factory and inserts it.TryGetValue(key, out value) is the safe read.Add(key, value) inserts a new entry and throws if the key is already present.Remove(key) deletes the entry explicitly (rarely needed, since the table cleans up automatically).Count and no enumerator that gives a snapshot. The "set of live entries" is inherently a moving target because GC can run between any two operations.Product instances with the same Id are different keys, even if your class overrides Equals. That's intentional: the table is built around object identity, not value equality.ConditionalWeakTable operations are O(1) on average, like a hash table, but the constant factor is higher than Dictionary<,> because the table coordinates with the GC to track key reachability. Treat it as roughly 2-5x slower than a regular dictionary on lookups. Use it when you need weak-key semantics; for everyday associations, a Dictionary<,> is faster.
The temptation, once you learn about weak references, is to use them whenever you have something that might be a leak. Three reminders to keep the tool in its lane.
Weak references are not a replacement for `IDisposable`. If a class owns an unmanaged resource (file handle, socket, native pointer), holding the wrapper weakly doesn't release the resource. The resource is freed only when the finalizer runs, which is unpredictable and slow. The _IDisposable & using Statement_ lesson covers "I'm done with this, release it now."
Weak references are not a replacement for a real cache. MemoryCache and similar libraries give you size limits, time-based expiration, eviction policies, and metrics. A Dictionary<int, WeakReference<T>> gives you "the GC decides when to drop entries." The two have almost nothing in common except the word "cache." If you need bounded memory use under load, use a cache library; if you need "remember this only while it's already alive for some other reason," use weak references.
Weak references are not free. Each WeakReference<T> allocates a small managed object and an entry in the runtime's GC handle table. ConditionalWeakTable adds further bookkeeping. On objects you create a few thousand of, the cost is invisible. On objects you create millions of, those handles add up.
Weak references can make code harder to reason about. A regular reference has clear lifetime semantics: it's alive until you let go. A weak reference's target can vanish between two consecutive lines of code. Most application code is better off avoiding the complexity entirely and structuring lifetimes explicitly through ownership, scoping, and IDisposable. Use weak references when you've identified one of the patterns this lesson covers, and structure the rest of the system around stronger guarantees.
9 quizzes