> ## Content Index
> Fetch the complete content index at: https://devinside.cloud/llms.txt
> Use this file to discover other available public pages before exploring further.

# The Allocation That Disappears: Escape Analysis from .NET 10 to .NET 11
- URL: https://devinside.cloud/the-allocation-that-disappears-escape-analysis-from-net-10-to-net-11/
- Published: 2026-11-10T12:45:00.000Z
- Updated: 2026-11-12T17:08:34.000Z
- Description: One compiled C# assembly, two pinned runtimes, and nine allocation scenarios. Real IL, JIT listings and measurements show when an object disappears, when nullable boxing moves off the managed heap, and why a local delegate can still allocate.
- Author: Marcin Borecki
- Tags: Engineering, Performance & Runtime, #rel02, #devinside-review

START WITH THE MECHANISM

Interactive companion and laboratory update: 12 November 2026\. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.

THE FULL INVESTIGATION

## The Allocation That Disappears: Escape Analysis from .NET 10 to .NET 11

Editorial context: 10 November 2026 · Prepared and updated: 12 November 2026

Dates and technology context

This is a retrospective editorial timeline. Topic context: 10 November 2026\. First publicly available: 12 November 2026\. Current text, visuals and laboratory revisions were prepared on 12 November 2026.

The same-binary two-runtime measurements, disassembly and scenario conclusions are a 2026-11-12 revision; do not imply that the .NET11 observations existed before GA.

The source contains `new`. The machine code contains an addition and a return. Somewhere between those two representations, the object stopped being necessary.

That is a useful result, but it is also easy to misunderstand. A local variable does not guarantee stack allocation. A zero in an allocation report does not mean that the operation has no memory cost. An optimization added to a runtime does not imply that every source pattern resembling its description will benefit.

The practical question is what the optimizer can establish about this particular object in this particular compilation. Who can observe it? How long must it remain usable? Can the JIT see the operation that consumes it? Does the implementation expose enough information to transform it safely?

This article follows a real, small laboratory executed on 12 November 2026\. It uses one compiled assembly on .NET 10.0.15 and .NET 11.0.0\. The samples cover an ordinary reference object, an explicitly constructed delegate and a boxed nullable value. Each family includes local use, an opaque call boundary and an escaping reference. Correctness checks and native-code inspection accompany the allocation measurements.

The most instructive case is not a universal success. The local delegate retains its allocation in the tested configuration on both runtimes. Keeping that case in the report makes the rule more useful: inspect the proof the implementation actually made, rather than the proof the source appears to suggest.

## Escape is a property of an observation boundary

01 / Watch the mechanism

### A local use and an observable escape

![A local integer result needs no retained object, while a field store makes the reference observable after return.](https://devinside.cloud/content/images/2026/11/devinside/REL02-lifetime.svg)

A local use and an observable escapeA local use and an observable escapeConceptual explanation of the executed laboratory.Local objectread field → integerReturn valueobject not observedEscaping objectstore referenceRetained fieldobserve after returnSame integer result; a different lifetime promise.

Play animation[Enlarge diagram](https://devinside.cloud/content/images/2026/11/devinside/REL02-lifetime.svg)

Static view. Play to see how the mechanism unfolds.

The escaping sample stores a NumberBox so another caller can inspect it. The local sample returns only an integer, allowing a different implementation without changing its contract.

Interpretation and evidence
- Follow references, not variable names.
- A field store changes the contract.
- Later identity can require a real object.
Conceptual explanation; native paths verified in jit-10.txt and jit-11.txt

Imagine constructing a small object, reading its integer field, adding three and returning the integer. If nothing else can observe the object, the implementation may only need the integer. Object identity, a stable heap address and a lifetime beyond the call contribute nothing to the required result.

Now store the object in a field that another caller can read after the method returns. The contract has changed. A future observer can distinguish the object from another instance and can inspect its value. Returning the correct integer is no longer the complete behavior.

There is a third case between those two: pass the object to a method whose body the current compilation cannot see. The callee might only read one field, but that fact may not be available to the caller's optimization. An object can therefore be short-lived in actual execution while remaining impossible to eliminate at the chosen compilation boundary.

The .NET 10 runtime documentation describes escape analysis and its relationship to stack allocation, including delegates and references stored in local structs. It also describes scalar promotion as a further opportunity after an object no longer needs a heap allocation. That is implementation capability, not a source-level promise that every local object takes a particular storage path. [Microsoft: .NET 10 runtime changes](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/runtime?ref=devinside.cloud)

The distinction between semantic lifetime and provable lifetime explains many disappointing benchmarks. The programmer knows that a helper is harmless. The JIT might not have that helper's body in the current optimization context. Conversely, a source abstraction that looks expensive may collapse when the compiler can see through it.

The right object to investigate is therefore a graph of uses, not merely the declaration line. Construction is the beginning of the story. Calls, stores, returns and later observations determine which parts of that construction must survive.

## Build once so that the runtime is the treatment

The laboratory compiles `EscapeLab.dll` once with SDK 10.0.400, targeting `net10.0`. The local `global.json` disables SDK roll-forward. Both execution paths use this exact DLL, and every report includes its SHA-256 hash.

One runtime configuration requests Microsoft.NETCore.App 10.0.15\. A second explicitly requests 11.0.0\. Both disable runtime roll-forward and tiered compilation. `dotnet exec --runtimeconfig` selects the configuration for the same assembly, avoiding an accidental comparison between two different C# compiler outputs. The command-line host supports supplying that runtime configuration explicitly. [Microsoft: the dotnet command](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet?ref=devinside.cloud)

This design has a deliberate scope. It examines runtime/JIT behavior for code compiled against the older target framework. It does not test new C# syntax, newly introduced framework APIs, or a complete application retargeted and rebuilt for .NET 11.

Tiered compilation is disabled because the first question is about the generated optimized method body under a stable configuration. The native listings report FullOpts without dynamic PGO data. This reduces one source of transition during short measurements, but it also excludes an important part of normal application behavior. A service using default tiering and profile-guided optimization needs its own follow-up comparison. [Microsoft: runtime compilation configuration](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/compilation?ref=devinside.cloud)

There is no claim that disabling tiering is a production recommendation. It is an experimental choice that must remain attached to the result. A benchmark that quietly changes such a setting can produce a plausible number while investigating a different optimizer from the one the application normally uses.

The three sample families are all synchronous. That makes a current-thread managed-allocation counter appropriate for this narrow harness. We are not attempting to follow allocations across asynchronous continuations or background workers.

## A reference object that becomes just an integer

The first family uses a small reference type with one integer field. The local path creates it and passes it to an inline-friendly reader:

```csharp
public sealed class NumberBox(int value)
{
    public int Value = value;
}

[MethodImpl(MethodImplOptions.NoInlining)]
public static int ObjectLocal(int value)
{
    var box = new NumberBox(value);
    return ReadInline(box);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int ReadInline(NumberBox box) => box.Value + 3;

```

The workload method itself is marked `NoInlining` so it remains a named boundary callable from the measurement loop. The small reader is marked `AggressiveInlining` to expose the intended opportunity. The attribute expresses a request; the native listing is the evidence of what happened.

On both tested runtimes, the generated `ObjectLocal` body consists of this arithmetic and return:

```asm
lea eax, [rcx+0x03]
ret

```

The listing reports four native-code bytes. There is no object-shaped stack area in this body and no allocation helper. Describing this simply as “the class moved to the stack” would miss the stronger result. The relevant value has been propagated into the computation, and the reference object does not appear as a separately materialized object in the shown body.

The work has not been optimized away incorrectly. The input is supplied by the caller, varies through the measurement, and contributes to an independently checked checksum. Correctness checks also call the method with negative, zero and larger inputs. The optimized result still represents the required computation.

This is a good example of why counting `new` expressions is not an allocation measurement. The source expresses a useful abstraction; the JIT may remove the implementation cost when the observable behavior permits it. It is equally a warning against the reverse inference: not seeing an allocation in one method does not imply that every caller, helper or neighboring operation is free.

## One call boundary can prevent the proof

02 / See the idea

### The helper body changes the available proof

![An inline-visible reader and an opaque reader consume the same NumberBox differently from the optimizer perspective.](https://devinside.cloud/content/images/2026/11/devinside/REL02-visibility.svg)

[Enlarge diagram](https://devinside.cloud/content/images/2026/11/devinside/REL02-visibility.svg)

The inline-visible object sample becomes an addition in both tested runtimes. The opaque variant still allocates, even though its helper only reads a field.

Interpretation and evidence
- Actual lifetime and provable lifetime differ.
- NoInlining is an experimental boundary.
- Check the generated code.
Conceptual explanation; native paths verified in jit-10.txt and jit-11.txt

The opaque variant differs in the reader it calls:

```csharp
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ObjectOpaque(int value)
{
    var box = new NumberBox(value);
    return ReadOpaque(box);
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static int ReadOpaque(NumberBox box) => box.Value + 3;

```

The helper is intentionally prevented from inlining. It does not store the object anywhere, and its source clearly performs the same addition. Nevertheless, the caller now has a boundary that it cannot simplify in the same way. In both native listings, `ObjectOpaque` contains a managed-allocation helper and a call to `ReadOpaque`.

This is not evidence that all method calls cause escape, or that every helper should be flattened. It is a controlled demonstration of one condition that changes the optimizer's knowledge. The article's conclusion concerns these bodies in these runtimes with these settings.

Removing `NoInlining` from a production helper is not automatically the right fix. The real helper might be large, polymorphic, rarely executed or intentionally isolated. Its inlining could increase native code size or change other optimization decisions. An attribute chosen to isolate a mechanism in a laboratory should not become a universal rule in an application's coding standard.

The useful design question is whether the abstraction exposes enough information on the hot path without making the code harder to maintain. Sometimes a small generic helper or a more direct data flow improves both readability and optimization. Sometimes the heap allocation is an acceptable cost for a boundary that carries important responsibilities.

Keep those judgments separate from this result. The experiment tells us that the boundary matters. It does not decide what the boundary is worth to a real system.

## An escaping object has a different contract

The third object variant stores the instance in a static field before returning its arithmetic result:

```csharp
[MethodImpl(MethodImplOptions.NoInlining)]
public static int ObjectEscapes(int value)
{
    var box = new NumberBox(value);
    RetainedObject = box;
    return box.Value + 3;
}

```

The store is observable. The correctness runner keeps the first instance, performs another call and verifies that the first value remains independent of the second instance. That test would fail if an implementation replaced all instances with one mutable shared object merely to make allocation numbers look attractive.

The benchmark retains only the latest instance through its static field, so it does not intentionally build an unbounded collection. The correctness test retains an earlier instance long enough to verify identity and value. These are different duties: one bounds the measurement, while the other protects the behavior whose cost is being measured.

The native listings contain both allocation and reference-assignment machinery for this path. That is not an optimization failure in the same sense as the opaque local use. The method now promises an object that can outlive the call. A stack location belonging only to the completed call could not satisfy that promise.

Returning the same integer across all three methods therefore does not make their complete contracts identical. The local and opaque variants are designed to compute the same result without external retention. The escaping variant intentionally adds an observable effect. It serves as a counterexample and boundary probe, not as a drop-in replacement that should be judged solely by elapsed time.

This distinction becomes important when someone proposes changing an API to accept a struct, returning a reference from a pool, or caching an object globally. Each change can alter lifetime, identity or sharing. Lower allocation is meaningful only after those changes are assessed against the application's requirements.

## Delegates are not one universal optimization pattern

04 / See the idea

### A local delegate can still allocate

![The explicit static Func sample retains an allocation and indirect invocation in both native listings.](https://devinside.cloud/content/images/2026/11/devinside/REL02-delegate.svg)

[Enlarge diagram](https://devinside.cloud/content/images/2026/11/devinside/REL02-delegate.svg)

The local delegate has no required later observer, yet this particular construction still allocates under the tested non-tiered configuration. This counterexample limits a general claim about all delegates; it does not disprove the runtime feature.

Interpretation and evidence
- One source shape is not every delegate.
- A smaller allocation is not elimination.
- Do not swap out inconvenient samples.
Actual measured values from measurements/summary.json

The delegate family constructs an explicit `Func<int,int>` for a static method. Explicit construction is important here: the sample is not intended to benchmark a compiler-generated cache for a noncapturing lambda or method-group conversion.

```csharp
[MethodImpl(MethodImplOptions.NoInlining)]
public static int DelegateLocal(int value)
{
    var operation = new Func<int,int>(AddThree);
    return InvokeInline(operation, value);
}

private static int AddThree(int value) => value + 3;

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int InvokeInline(Func<int,int> operation, int value)
    => operation(value);

```

The runtime work in pull request 115172 extends escape analysis for delegate invocation. Its description explains special treatment of the delegate receiver and a rewriting step that exposes accesses for further optimization. That is specific implementation work. It does not say that every delegate constructor and invocation will disappear under every compilation configuration. [dotnet/runtime pull request 115172](https://github.com/dotnet/runtime/pull/115172?ref=devinside.cloud)

Our local sample is a useful limit case. Both tested runtimes still emit an allocation helper and an indirect delegate invocation in `DelegateLocal`. The native body is 83 bytes in both listings, although internal field offsets differ. The important observation is the remaining construction and invocation path, not an assumption that equal code length means identical implementation.

The experiment does not identify every internal reason why this shape remains materialized. Its explicit static delegate representation, the receiver relationships visible to the JIT and the absence of dynamic profile information are conditions that should stay attached to the result. It would be premature to announce a general delegate escape-analysis bug from this one example.

We deliberately keep the sample rather than replacing it with another shape until the chart looks better. A second study could compare cached noncapturing lambdas, closed instance delegates and different call boundaries. That would answer a broader question. Quietly swapping the workload would instead erase the counterexample that makes the present article honest.

The opaque and escaping delegate variants strengthen the boundary comparison. One passes the delegate to a non-inlined consumer; the other retains it in a field. The correctness runner verifies that explicitly constructed retained delegates have independent identity and remain callable after another instance is installed.

## Nullable boxing has two different branches

03 / See the idea

### One IL box, two observed native paths

![The same nullable boxing IL uses a helper on .NET 10 and local stack storage on .NET 11 in this sample.](https://devinside.cloud/content/images/2026/11/devinside/REL02-nullable.svg)

[Enlarge diagram](https://devinside.cloud/content/images/2026/11/devinside/REL02-nullable.svg)

Both executions use the identical DLL. The local value-bearing nullable path retains its null branch while its managed heap allocation disappears on the tested .NET 11 runtime.

Interpretation and evidence
- The IL is identical.
- Null and value branches remain distinct.
- Stack work is not zero total cost.
Actual measured values from measurements/summary.json

A nullable value can have no value, or it can contain a value of its underlying type. Boxing must preserve that distinction. A nullable without a value becomes a null reference; a nullable with a value boxes the underlying value rather than producing a normal boxed `Nullable<T>` object. [C# reference: nullable value types and boxing](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/nullable-value-types?ref=devinside.cloud)

The local sample crosses an object-shaped boundary and immediately reads it:

```csharp
[MethodImpl(MethodImplOptions.NoInlining)]
public static int NullableLocal(int? value)
{
    object? boxed = value;
    return ReadNullableInline(boxed);
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int ReadNullableInline(object? value)
    => value is int number ? number + 3 : -1;

```

The input is `int?`, so the method has to handle both branches. Correctness checks cover null separately and verify the underlying boxed type on the escaping path. The timed workload uses values, not nulls. That choice matters: an allocation-free null case would not demonstrate removal of a value-bearing box.

The same compiled IL is visible in both inspections:

```il
IL_0000: ldarg.0
IL_0001: box System.Nullable`1[System.Int32]
IL_0006: call Int32 ReadNullableInline(System.Object)
IL_000B: ret

```

Pull request 122167 concerns inlining nullable allocation logic so subsequent optimization can see more than an opaque boxing helper. Its motivation and patch make the direction clear, while the patch's own conditions remind us that eligibility has limits. A merge date is implementation provenance; the actual runtime observation is needed to establish what our installed version does. [dotnet/runtime pull request 122167](https://github.com/dotnet/runtime/pull/122167?ref=devinside.cloud)

On .NET 10.0.15, our local native listing calls `CORINFO_HELP_BOX_NULLABLE`. On .NET 11.0.0, that helper is absent. The new listing checks whether a value exists and uses stack-relative storage for the value-bearing representation before reading the integer. That is a different outcome from the ordinary object sample's complete scalar simplification.

Both can avoid a managed heap allocation, but they should not receive the same explanation. One shown body is essentially an addition. The other still has branches and stack work. A zero in the managed-allocation column cannot distinguish them; the native listing helps explain the difference.

## The counterexample is part of the result

Passing the nullable box to `ReadNullableOpaque` prevents inlining of its consumer. Storing the box in `RetainedNullable` makes it observable after the call. These two variants test whether the optimization opportunity survives when the JIT sees less, or when the contract requires more.

The .NET 11 listings for both paths still contain an allocation helper. The helper has changed from the nullable boxing path used by .NET 10, but the object remains required in the generated code. Eliminating one helper call is therefore not equivalent to eliminating allocation.

This is a common source of overconfident disassembly commentary. A recognizable helper disappears, and the article declares the operation free. The allocation may have been expanded, moved or replaced by a different helper. A credible explanation needs both a measurement with a clear accounting boundary and a reading of the resulting data flow.

The native body can also become longer while avoiding heap work. The local nullable method is 53 bytes in the .NET 10 listing and 72 bytes in the .NET 11 listing. Those numbers describe only the listed body; the old helper's code is outside it. They do not prove that the new implementation has a larger total footprint or that it is slower.

That is why native code size, managed allocated bytes and elapsed time appear as distinct observations. Each answers a different question. The optimizer may trade extra local instructions for avoiding a helper and heap object. Whether that trade is useful depends on execution frequency and the surrounding workload.

The strongest practical rule is not “locals go on the stack.” It is “the optimizer needs a visible, eligible path on which object identity and extended lifetime are unnecessary.” Even then, the implementation must actually perform the transformation. Our delegate sample shows the gap between a plausible opportunity and an observed result.

## How the harness protects the comparison

Before timing, each runtime executes 42 checks. The arithmetic expectation is independent of the workload method: for each non-null input, the result must be the input plus three. Additional checks cover null boxing, retained-object identity, callable retained delegates and the underlying type of a boxed nullable value.

The timed loop varies its input through 32 values and accumulates every returned result. The checksum is verified outside the measured interval. This prevents a workload that returns a constant or omits required work from silently producing a flattering result.

The host selects one scenario before the loop and stores its delegate outside the measured region. For nullable methods, a fixed adapter converts the integer input to `int?`. That adapter is part of the call path, so the experiment compares the same path on each runtime rather than pretending to measure an isolated instruction.

Each process performs a first invocation, a fixed warm-up and seven measured samples. The orchestration runs two rounds with reversed runtime order. Full collections occur before samples and outside timing. The output retains raw timestamp ticks, frequency, managed allocated bytes, checksum and collection count.

Allocation is measured on the executing thread because the work is synchronous and remains on that thread. It excludes native allocations and does not describe the retained heap after the process has finished. A future asynchronous adaptation would need a different allocation boundary rather than blindly reusing this counter. [GC.GetAllocatedBytesForCurrentThread](https://learn.microsoft.com/en-us/dotnet/api/system.gc.getallocatedbytesforcurrentthread?view=net-10.0&ref=devinside.cloud)

This is an intentionally transparent BCL harness, not BenchmarkDotNet. It does not claim automatic statistical calibration, confidence intervals or a complete benchmark framework's diagnostics. The raw results and exact loop make its strengths and limits inspectable.

The shared Windows machine is another limit. Sequential execution avoids deliberately competing benchmark processes, but background activity still exists. Extremely small timing differences should not become production recommendations. Stable allocation observations paired with matching native-code evidence can support a narrower and more defensible mechanism claim.

## Read the measurements as three separate stories

05 / See the idea

### Measured managed bytes per operation

![The allocation matrix reports local, opaque and escaping cases on .NET 10.0.15 and .NET 11.0.0.](https://devinside.cloud/content/images/2026/11/devinside/REL02-results.svg)

[Enlarge diagram](https://devinside.cloud/content/images/2026/11/devinside/REL02-results.svg)

Each cell shows the two runtime values for the same scenario. Nullable local reaches zero only in the tested .NET 11 path, whereas the delegate cases still allocate.

Interpretation and evidence
- Read each family separately.
- The counter excludes native allocation.
- Timings and ranges remain in the article.
Actual measured values from measurements/summary.json

The final table reports all nine scenarios for both pinned runtimes. It is filled from the executed process outputs, not from the pull requests or a product-team benchmark. The sample ranges are ranges of sample means, not individual request percentiles and not confidence intervals.

All 42 checks passed on each runtime before measurement. The measurement script then completed 36 sequential processes, producing 252 measured samples in total. Each row/version has two process launches and 14 samples of 200,000 operations. The input sequence and binary hash match across the versions. The host reports an AMD EPYC 7502P processor, Windows build 10.0.20348, x64, 64 logical processors and workstation GC, with tiered compilation disabled.

| Scenario                  | .NET 10 median ns/op | .NET 10 sample range | .NET 11 median ns/op | .NET 11 sample range | Managed B/op, 10 / 11 |
| ------------------------- | -------------------- | -------------------- | -------------------- | -------------------- | --------------------- |
| Object, local             | 2.56                 | 2.39–2.72            | 6.30                 | 4.45–6.34            | 0 / 0                 |
| Object, opaque call       | 6.14                 | 6.09–14.67           | 9.63                 | 7.95–15.14           | 24 / 24               |
| Object, escapes           | 8.22                 | 7.90–16.11           | 11.11                | 8.47–19.71           | 24 / 24               |
| Delegate, local           | 11.25                | 10.82–26.96          | 13.92                | 12.42–26.55          | 64 / 56               |
| Delegate, opaque call     | 13.76                | 11.48–25.94          | 14.92                | 12.94–28.58          | 64 / 56               |
| Delegate, escapes         | 14.81                | 13.31–28.36          | 16.26                | 14.53–30.62          | 64 / 56               |
| Nullable box, local       | 19.82                | 19.40–27.99          | 3.34                 | 3.29–3.39            | 24 / 0                |
| Nullable box, opaque call | 20.62                | 19.61–27.54          | 9.84                 | 6.98–17.50           | 24 / 24               |
| Nullable box, escapes     | 24.65                | 24.30–31.65          | 8.87                 | 8.78–17.49           | 24 / 24               |

The allocation value for each row/version was identical across its samples. The local nullable case is the clearest version-dependent allocation result: 24 managed bytes per operation on .NET 10.0.15, and none observed on .NET 11.0.0\. The inspected stack-based representation supports a concrete explanation for that change. The opaque and escaping nullable cases still allocate 24 bytes, even though their measured elapsed times are lower on the newer runtime.

All three explicit delegate cases allocate fewer bytes on .NET 11, but none reaches zero. The reduction from 64 to 56 bytes is not evidence that escape analysis removed the delegate. The listings still contain allocation machinery, and the same reduction occurs in the escaping case. Attributing every byte difference to the named escape-analysis PR would therefore be unjustified. A smaller observed allocation and an eliminated allocation are different findings.

The ordinary local object gives an equally important timing warning. Its named native body is the same four-byte addition/return on both versions, while its measured loop time is higher on .NET 11 in this run. The measurement also includes the host's call path and loop; the rest of the compiled process is not made identical by finding one identical method body. With only two launches per version, on a shared host, this result does not establish a production regression or identify its cause.

These are mechanism-oriented local observations, not a broad runtime ranking. The fixed iteration count was selected to keep the shared-machine work bounded, not through automatic statistical calibration. The table retains unfavorable values and wide sample ranges so readers can judge that limitation directly. A deployment decision about nanosecond differences requires a stronger experiment around the actual hot path.

The ordinary object family asks whether the JIT can eliminate a representation it can fully see. The delegate family asks whether a particular construction and invocation path receives comparable treatment. The nullable family asks whether exposing allocation logic creates an opportunity that the older helper boundary concealed.

Do not average these stories into one runtime score. The workload mix in a real application determines how frequently each pattern matters. A service allocating mostly strings and buffers may be unaffected by an improvement to this nullable pattern. Another hot path may execute it often enough that the change materially reduces collection pressure.

Likewise, an escaping case is not a failed implementation merely because its number is higher. The application asked for a retained object. Comparing it with a nonescaping integer computation helps explain the cost of that promise, but does not authorize removing the promise from the API.

The useful negative result is the local explicit delegate. Its continued allocation tells us that our source-level intuition was insufficient in this configuration. It creates a specific next question about shape and optimization visibility, rather than a vague conclusion that escape analysis either always works or does not exist.

There is also a boundary on the word zero. The object-local and .NET 11 nullable-local samples observed no new managed heap bytes on the measuring thread within the loop. The nullable native body still uses stack storage. The process had already allocated its host objects, selected its delegates and initialized the runtime. None of those costs is erased by the zero in this column.

## Reproduce the experiment without changing its identity

Build from the companion `lab` directory so its SDK selection applies:

```powershell
dotnet build -c Release --nologo
dotnet exec --runtimeconfig bin/Release/net10.0/EscapeLab.runtimeconfig.json bin/Release/net10.0/EscapeLab.dll check
dotnet exec --runtimeconfig Runtime11.runtimeconfig.json bin/Release/net10.0/EscapeLab.dll check

```

The reports must identify 10.0.15 and 11.0.0 respectively and contain the same assembly hash. If they do not, stop interpreting the comparison until runtime selection is understood. An installed SDK version alone does not prove which runtime executed a generated or existing application.

The `inspect` command decodes workload IL and resolves relevant metadata tokens. The `measure` command takes the scenario and bounded iteration count. The companion orchestration script runs the selected matrix serially and preserves stdout, stderr and exit status for every process.

For native listings, a separate correctness execution sets `DOTNET_JitDisasm=EscapeLab.Workloads:*` and directs the output to a file. No diagnostic output is enabled during timed runs. Because both runtime configurations already disable tiering, the diagnostic and timed paths share that setting, although the diagnostic process itself is not a timing observation.

Reproduction should begin with the unchanged sample. Then change one relevant premise. Remove the opaque boundary, retain an additional reference, choose a closed delegate or use a different deployment mode. Preserve the original report so that a new result has an explicit relationship to the old one.

The next experiment should answer a question, not merely seek a more attractive zero. If a different delegate shape optimizes well, explain why that shape differs. If enabling normal tiering changes behavior, report it as a new configuration rather than retroactively treating it as the one already measured.

## What this should change in application code

The immediate lesson is restraint. A class is not automatically an expensive choice, and a struct is not automatically a better one. Replacing reference semantics with value semantics can change copying, identity, sharing and mutation. Those consequences matter independently of allocation.

A useful first action is to inspect the path that creates meaningful allocation pressure in the real application. Determine whether objects escape because the product needs them later or because an implementation boundary conceals a simpler lifetime. Those are different opportunities with different risks.

When the lifetime is genuinely local, make the data flow understandable. Small helpers, concrete types where appropriate and avoidable indirection can matter. Do not begin by scattering inlining attributes throughout a repository. The laboratory uses attributes to control visibility; production code needs evidence that the change helps the relevant workload without creating unacceptable code growth or maintenance cost.

When the object must escape, consider the actual requirement before introducing pooling or caching. Reusing an object can lower allocation while creating concurrency and stale-state problems. A pool also has its own ownership contract. That is a separate design topic, not a free consequence of learning that an allocation remains.

For a runtime upgrade, retain meaningful correctness checks and measure the workload that motivated the investigation. This article's binary-on-two-runtimes method isolates one dimension, but a real upgrade can include retargeting, package changes and altered deployment settings. Keep those changes distinct enough to understand their contributions.

The allocation that disappears is interesting because it shows the runtime doing work on our behalf. The allocation that remains is equally useful when it reveals an observable lifetime or an optimization boundary. Both results can improve engineering judgment, provided the explanation stays attached to the actual code and the actual experiment.

## Source code and execution evidence

[Download the complete lab, source record and raw execution results](https://devinside.cloud/content/files/2026/11/REL02-lab.zip). This package supports the specific execution claims in this article. It is not a production certification.