Begin with the ownership question
The most useful first question in a .NET memory incident is often who still owns this object. A collector can correctly preserve a graph that the application no longer intends to retain. A small cache entry, callback or pending task may lead to a much larger payload. Calling collection more often cannot remove an intentional strong path, and changing GC mode cannot decide which business result should have expired. Start by describing the lifetime that was intended and the observation that violates it.
This article follows one concrete case: a cache stores tiny views into large input arrays. The visible values are small, yet their backing storage remains large. We then connect that case to request concurrency, pools, diagnostic metrics and a repeatable production investigation. SharpRune's Memory Lab provides a manipulable ownership diagram; the downloadable console project provides real managed-array observations. Their outputs answer different questions, so neither is presented as a substitute for a production heap investigation.
The goal is a chain of evidence. Record a workload boundary, observe retained types, find representative retaining paths, change one ownership policy and rerun the same workload. If the proposed explanation cannot be contradicted by a measurement, refine it before choosing a tuning knob.
References: Memory and Span ownership
Separate four meanings of memory
Allocation is a flow: how much storage was handed out during an interval. Retained live data is a stock: what remains owned at an observation point. GC committed memory includes capacity held by the collector, including space available for future managed allocations. Process working set describes resident process memory and includes categories outside the managed heap. These quantities can move in different directions without any of their measurements being wrong.
A service can allocate rapidly while returning to a stable live set after every batch. Another service can allocate slowly but retain every result forever. A successful ownership fix may reduce the later live set while working set remains high because the process or runtime retains reusable capacity. Conversely, a flat last-GC heap snapshot does not prove that all current process memory is stable. Always name the metric, unit, scope and observation window.
Avoid subtracting an arbitrary managed snapshot from a working-set sample and labeling the remainder exactly native memory. Sampling times, shared pages, heap fragmentation and committed capacity complicate that subtraction. Use the mismatch as a reason to investigate another category, not as a precise allocation account that the evidence does not support.
References: Memory and Span ownership
A twelve-byte view can own much more
Memory and ReadOnlyMemory are views, not automatic copies. A short slice can keep an entire backing array reachable. This makes parsing efficient when views stay within a short operation, but it can become expensive when a long-lived cache stores small fields from large temporary inputs. The number of visible bytes is not necessarily the number of retained backing bytes.
Our console example creates three separate one-megabyte arrays, writes a marker into each and retains a twelve-byte view from each array. Inspecting array backing through MemoryMarshal shows three distinct arrays and their full capacities. Copying each selected view into an independent small array preserves the logical values while changing the backing-store ownership. The program reports those managed facts without claiming that the old arrays were immediately collected or that process RSS fell by an exact amount.
That distinction matters during diagnosis. A local ArraySegment extracted to inspect the backing array is itself another strong owner. Keeping diagnostic locals or collections around can alter the lifetime you are trying to measure. Explain what your code proves, clear inspection-only owners when appropriate and use comparable later snapshots for reclamation claims.

.NET10 SDK / desktop CoreCLR
using System.Runtime.InteropServices;
var cached = new List<ReadOnlyMemory<byte>>();
for (int i = 1; i <= 3; i++)
{
var input = new byte[1_048_576];
input[0] = (byte)i;
cached.Add(input.AsMemory(0, 12));
}
var before = Backing(cached);
Console.WriteLine($"before:arrays={before.Arrays};backing-bytes={before.Bytes};logical-bytes={cached.Sum(v => v.Length)}");
cached = cached.Select(v => (ReadOnlyMemory<byte>)v.ToArray()).ToList();
var after = Backing(cached);
Console.WriteLine($"after:arrays={after.Arrays};backing-bytes={after.Bytes};logical-bytes={cached.Sum(v => v.Length)}");
Console.WriteLine($"marker-sum:{cached.Sum(v => (int)v.Span[0])}");
Console.WriteLine("evidence:backing identity and capacity;not immediate collection or RSS");
static (int Arrays, long Bytes) Backing(IEnumerable<ReadOnlyMemory<byte>> views)
{
var seen = new HashSet<byte[]>(ReferenceEqualityComparer.Instance);
foreach (var view in views)
{
if (!MemoryMarshal.TryGetArray(view, out var segment) || segment.Array is null)
throw new InvalidOperationException("This lab requires array-backed views.");
seen.Add(segment.Array);
}
return (seen.Count, seen.Sum(array => (long)array.Length));
}
References: TryGetArray
Sharing changes the arithmetic
Three views into three arrays have a different retained shape from three thousand views into one shared array. Counting every view's complete backing capacity independently would overcount shared storage. A useful investigation tracks reference identity as well as logical length. Type counts alone cannot tell you whether buffers are shared, copied or owned independently.
The example's backing-store summary uses reference identity to count distinct arrays. Its reported byte total is the sum of those arrays' element capacities, not object headers, allocator metadata or physical resident pages. It is intentionally a narrow application-level measurement. If a view comes from a custom memory manager rather than an array, TryGetArray can fail and the investigation must respect that abstraction instead of inventing an array behind it.
A small copy is not always an improvement. Copying every temporary parser slice can increase allocation throughput and CPU work. The relevant boundary is where a small selected value needs to outlive a much larger input or remain independent of future mutation. Compare both lifetime and throughput under the intended consumer behavior, rather than treating copying or zero-copy as a universal design rule.
References: TryGetArray
Use the interactive graph to make a prediction
Open the Memory Lab and choose the small-view scenario. It starts with a cache path and a request path reaching the same backing array. Remove the cache entry and inspect the remaining request path. The large array stays reachable because one ownership change did not remove every owner. End the request too, then collect a generation that includes the modeled large-object heap.
The diagram explicitly defines its roots and uses conceptual object sizes. Its view box is a teaching convenience, not a claim that every Memory struct becomes a separately allocated object of that size. Its collection steps are deterministic so the learner can compare predictions with outcomes. It does not inspect the real heap of the browser or phone, show physical addresses, or model every CoreCLR optimization.
Use that clarity to prepare a real investigation. Write down the path you expect to find in a dump, the lifecycle event that should remove it and the observation that would show a second owner remains. A good teaching model reduces ambiguity before you collect a large diagnostic artifact; it should never be mistaken for measured production evidence.
References: Debug a memory leak
Trace callbacks and pending operations too
Caches are only one source of retention. A long-lived publisher can retain a delegate, its target and the target's payload after a screen or request owner ends. A pending asynchronous operation can retain state needed after a suspension. Cancellation registrations can retain explicitly supplied state even when their callbacks are static. Each case is an ownership graph with a different ending event.
Unsubscribe the exact intended event handler, dispose registrations according to their concurrency contract and give pending work a bounded completion policy. Cancelling a token does not automatically erase queued objects, stop arbitrary code or return every rented buffer. A completed task can remain reachable through a history list and retain its result or exception. Inspect the owner that actually remains instead of assuming one language feature is inherently a leak.
The Memory Lab's event scenario demonstrates the second path, while the course provides real cancellation and callback examples. In a production incident, correlate retained payloads with in-flight operations and downstream waiting reasons. An outage can extend legitimate lifetimes across many requests without creating a permanent static cache. That still requires an overload response, but the appropriate fix differs from deleting a forgotten dictionary entry.
References: Debug a memory leak
Generations do not replace reachability reasoning
A minor collection considers a limited reclamation set. Older objects and references from older storage can still matter to younger objects. Removing an application's last root to an old object does not require the next Gen 0 collection to reclaim that old graph. Likewise, large-object and pinned-object heaps participate in the appropriate full-generation collection behavior of CoreCLR rather than being arbitrary labels for all arrays.
Generation is not a clock that reports an object's business age or remaining lifetime. A freshly allocated large array can report generation 2, while an ordinary object can remain young until relevant collection occurs. Pinning an existing array does not move it to POH. These distinctions prevent a profiler observation from being converted into an incorrect rule such as old objects never die or a generation-2 object must be a leak.
Use collection and generation counters to characterize workload behavior, then inspect retained types and paths to explain it. Forced collections in isolated educational SDK experiments make specific mechanisms observable; adding them to every request changes production behavior and can obscure the original pressure pattern. First correct ownership and allocation shape, then evaluate collector policy with representative evidence.
References: Runtime metrics
Pools need a separate lifetime account
Pooling can reduce repeated allocation while retaining more capacity for reuse. A pool's idle high-water mark is not the same thing as active request ownership. Count them separately and avoid adding the same active rental to two categories. A process with lower allocation rate may still have a larger steady-state managed heap after pooling is introduced.
A returned array can remain strongly reachable but be invalid for the previous renter to use. Reachability is a memory-management fact; a pool lease is an application protocol. Return storage only after every asynchronous borrower has finished, return it at most once and expose only the logical written region. Clearing policy should address confidentiality and retained references without being tested by reading an array after its lease ended.
When comparing pooling with fresh allocation, include success, cancellation and exception paths. Record peak rentals, sizes, return behavior and output correctness. A tracking pool in a test can establish ownership counts; a benchmark can measure allocation and throughput; a heap investigation can attribute retained buffers. No single one of those tools proves all three kinds of claim.
References: Runtime metrics
Overload can look like a leak
If producers create work faster than consumers finish it, pending tasks and queued payloads remain reachable. A bounded channel does not bound all memory if an unbounded number of producers allocate large payloads before awaiting a write. The channel can be full of a few items while thousands of waiting callers retain buffers outside it.
Acquire bounded admission before expensive payload materialization where the protocol permits, and decide what a full boundary does: wait within a bounded policy, reject, or drop only where loss is allowed. Budget active work, queued work, waiting callers, simultaneous copies and idle pools. A slow downstream operation increases the duration for which those owners remain live, so normal-load measurements can miss the failure mode.
The lab calculator illustrates this ownership arithmetic with explicit assumptions. It does not claim that a finite payload budget equals measured RSS or predicts p99 latency. Use it to find a plausible capacity envelope, then run a representative constrained workload. Across replicas, remember that each process has its own baseline and pools; scale-out can lower per-instance active work while increasing total retained idle capacity.
References: dotnet-dump
Collect diagnostic evidence in stages
Begin with low-overhead metrics over a reproducible workload interval. Record process identity, runtime, deployment, traffic phase and relevant quotas. Use current runtime metrics or the appropriate version's counters to distinguish allocation rate, collection activity, committed GC memory and process memory. Preserve timestamps and collection indexes where the API reports a last-collection snapshot.
If retained managed data keeps growing across comparable boundaries, inspect a type distribution and representative objects. In a matching dotnet-dump analysis, dumpheap can help locate candidates and gcroot can explain why one is reachable. Addresses belong to that dump's context, not to a permanent application identity. A root path is evidence of ownership; whether that ownership is wrong still depends on the intended lifetime.
Choose diagnostic tools with their effects in mind. A managed GC dump can trigger collection and reconstruct graph evidence, so it is an intervention rather than a passive photograph of an untouched process. Native and other process-memory growth need suitable additional evidence. Keep diagnostic artifacts under the application's approved access and storage practices, because their contents can include real application data.

References: dotnet-dump
Turn one hypothesis into a controlled fix
Suppose the candidate path is cache to short view to large array. State a hypothesis: copying the small durable field at the cache boundary will reduce retained backing capacity after requests complete. Keep the input distribution, request count and retention policy fixed. Verify that the copied result preserves values and independence from source mutation before measuring memory behavior.
Rerun the same workload and inspect a comparable lifecycle boundary. If the large arrays remain, find another retaining path rather than repeatedly forcing collection until a preferred chart appears. An active request, inspection collection, event subscription or completed task history may still own them. If process memory remains high while managed live evidence improves, investigate reusable capacity and nonmanaged categories instead of declaring the ownership fix ineffective automatically.
Acceptance should include correctness, peak memory, recovery after idle, allocation cost and latency. A change that saves retained bytes by copying excessive data on every request can fail the workload objective. Record the tradeoff with a defined experiment so the next engineer can reproduce the result and challenge the conclusion when input sizes or traffic patterns change.
References: dotnet-gcdump
A practical exercise in SharpRune and the SDK
Run the portable backing-array program and predict its distinct-array count before reading the output. Change the sample so all views share one input array, then explain why the number of views increased without multiplying the backing total in the same way. Copy one view and mutate its original source, checking which values should change and which should remain independent.
Next, use the interactive cache and event scenarios to identify two different retaining paths to one payload. Remove only one path and explain the result. Switch to the generational scenario to separate a missing application root from an object outside a minor collection's reclamation set. These exercises train the vocabulary needed to read an actual dump without treating every edge as an error.
Finally, run the desktop experiment under the stated SDK, save the output and write down what it establishes. The backing-capacity facts are deterministic; collector and process counters are runtime observations. Practice explaining both in an interview: who owns the storage, which boundary changes ownership, which measurement would confirm the intended effect, and which tempting claim the current evidence cannot justify.
References: dotnet-gcdump
Keep the incident report useful to the next person
A useful memory report connects a symptom to an ownership mechanism and a tested intervention. Include enough information to reproduce the workload and identify the process, but avoid an unstructured archive of every available counter. Show the representative type, the relevant root path, the intended ending event and the before-and-after comparison at comparable boundaries. State when a tool deliberately triggered collection.
Preserve alternative explanations when the evidence is incomplete. A flat managed snapshot with rising working set is a reason to investigate other categories, not a proof that no memory problem exists. A large cache can be legitimate under a stated capacity policy. A few finalizable wrappers can retain surprising child graphs while pending cleanup. The report should make those possibilities distinguishable rather than collapsing them into the phrase GC problem.
Keep a small regression exercise for the ownership contract that was repaired. It might assert eviction membership, callback unsubscription, independent copied values or exactly one pool return on every accepted lease. Those checks protect behavior without pretending to assert an exact future heap layout. Repeat performance measurements when the workload or runtime changes, and keep the mechanism clear enough that the next result can be explained rather than merely celebrated.
References: GC fundamentals
Use an interview answer as a diagnostic rehearsal
When asked why a tiny object retains a large amount of memory, start by requesting the ownership graph and the meaning of the reported size. A strong answer distinguishes shallow storage from retained backing capacity, identifies whether the array is shared and names every relevant strong path. It then proposes a concrete change at a lifetime boundary, such as copying a small durable result, and explains how to verify both correctness and the later retention effect.
Continue with a counterexample. If an active request still owns the original array, evicting the cache entry alone will not release that ownership. If three thousand views share one array, multiplying its capacity by three thousand is wrong. If the buffer was returned to a pool, a surviving reference does not grant permission to use it. These counterexamples make the answer useful outside an interview because they expose assumptions that a production measurement must test.
Finish by stating the evidence boundary. A program printing array lengths proves those lengths and identities; it does not prove an immediate reduction in process resident memory. That disciplined explanation is more valuable than a memorized list of GC generations without a connection to the actual incident.
References: GC fundamentals
Run the complete SDK project
.NET10 SDK / desktop CoreCLR
Download the complete project, read README.md and execute in a separate local SDK process. Cloud hosting and production load behavior require independent qualification.
dotnet run -c Release --project MemoryLab.csprojDownload complete project ZIPContinue with SharpRune
Sources & further reading
Technical references checked against primary documentation. Browser playground examples target the SharpRune .NET 9 profile; production server guidance describes a separate hosting environment.
