How Weak References Prevent Memory Leaks in C

In C#, in-memory caching often leads to memory leaks when objects remain referenced by cache collections long after the rest of the application has stopped using them. This article explains how strong references cause unbounded memory growth, how the WeakReference<T> class allows the .NET Garbage Collector (GC) to reclaim memory automatically, and how to properly implement weak references to build memory-safe caches.

The Problem: Strong References and Stale Cache Entries

By default, assigning an object to a variable, list, or dictionary in C# creates a strong reference. The .NET Garbage Collector determines an object's lifetime by tracing object graphs from application roots (such as static fields, local variables, and CPU registers). As long as a strong reference exists within an active cache:

  • The GC considers the object actively in use.
  • The memory occupied by the cached object cannot be freed.
  • Memory consumption grows continuously unless an explicit eviction policy (like TTL or LRU) manually removes the entry.

In high-throughput or long-running applications, failing to actively evict unneeded cache items results in memory leaks, degraded GC performance, and eventual OutOfMemoryException crashes.

How Weak References Solve the Problem

A weak reference references an object while still allowing the Garbage Collector to collect it. In .NET, this is implemented using the WeakReference<T> class.

When an object is only referenced through a WeakReference<T>:

  1. It is not considered a root or a reachable object during a garbage collection cycle.
  2. If memory pressure arises or the GC initiates a collection for the generation the object resides in, the GC frees the object's memory.
  3. The internal pointer of the WeakReference<T> is automatically updated to track that the target has been collected.

By storing weak references in a cache, the cache acts as an opportunist: it retains objects for fast retrieval as long as memory permits, but safely surrenders them to the GC without causing memory leaks.

Retrieving Cached Objects Safely

Because the GC can collect weakly referenced targets at any time, you cannot access the value directly as you would with a standard reference. Instead, you must attempt to re-establish a temporary strong reference using the TryGetTarget method.

public class WeakCache<TKey, TValue> where TValue : class
{
    private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new();

    public void Set(TKey key, TValue value)
    {
        _cache[key] = new WeakReference<TValue>(value);
    }

    public bool TryGetValue(TKey key, out TValue? value)
    {
        if (_cache.TryGetValue(key, out var weakRef))
        {
            // TryGetTarget creates a strong reference if the object is still alive
            if (weakRef.TryGetTarget(out value))
            {
                return true;
            }

            // The object was collected; remove the stale key
            _cache.Remove(key);
        }

        value = null;
        return false;
    }
}

When TryGetTarget succeeds, it assigns the object to a local variable (value), creating a strong reference that protects the object from GC collection for the duration of that variable's scope. Once the consumer finishes execution and the local reference leaves scope, the object becomes eligible for collection once again.

Managing Cache Key Overhead

While WeakReference<T> prevents the cached values from causing memory leaks, it does not automatically remove the keys or the wrapper instances from the underlying dictionary.

If millions of unique keys are placed into a cache, the empty WeakReference<T> wrappers and key objects themselves will remain in memory. To address this:

  • Prune on Access: Remove dead entries when TryGetTarget returns false during lookups.
  • Periodic Cleanup: Run a background cleanup loop to scan the cache and remove keys whose targets have returned false.
  • Use ConditionalWeakTable<TKey, TValue>: For scenarios where keys are also reference types, System.Runtime.CompilerServices.ConditionalWeakTable<TKey, TValue> automatically removes both the key and the value once the key becomes unreachable, completely eliminating manual dictionary cleanup.