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

# A Timeout Is Not Cancellation: Task.WaitAsync, Timers, and the WhenAny Trap
- URL: https://devinside.cloud/a-timeout-is-not-cancellation-task-waitasync-timers-and-the-whenany-trap/
- Published: 2025-09-04T13:00:00.000Z
- Updated: 2026-11-12T17:08:25.000Z
- Description: A bounded C# lab separates the lifetime of an operation, its waiting caller and its timer, with deterministic correctness tests and measured cleanup behavior.
- Author: Marcin Borecki
- Tags: Engineering, Performance & Runtime, #rel04, #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

## A Timeout Is Not Cancellation: Task.WaitAsync, Timers, and the WhenAny Trap

Editorial context: 4 September 2025 · Prepared and updated: 12 November 2026

Dates and technology context

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

Keep SDK10.0.400/runtime10.0.15, 49 checks, timer measurements, runtime-source comparison and the proposed CA2027 availability discussion in the 2026-11-12 update. Do not describe WaitAsync as a .NET10/11 invention.

A caller stops waiting after a deadline. Somewhere else, the operation finishes and commits its result. If the application reports that the work was cancelled, it has described a state it never established.

The confusion often begins with a few reasonable-looking lines of asynchronous C#. Start an operation, race it against a delay, and throw if the delay wins. The code appears to have added a timeout to the operation. More precisely, it has added a timeout to one observer's willingness to wait. That distinction affects correctness, resource ownership and the meaning of a retry.

There is another side to the race. If the operation wins, what happens to the delay? An abandoned timer is less dramatic than a duplicated payment, but it can still retain work after the useful request has ended. A short idiom can hide two unfinished obligations: the losing wait and the underlying operation.

This article makes those obligations visible with a runnable, bounded laboratory. The code compiled and passed 49 checks on .NET 10.0.15 using SDK 10.0.400\. Tests control the ordering of completion and timer callbacks instead of relying on arbitrary sleeps. The measurement harness limits each batch to 128 operations and cleans up every timer before starting another batch. No server, external effect or large collection of long-lived background work is involved.

The context is 12 November 2026\. The chosen versions identify this execution environment. They do not imply that `WaitAsync` is a new .NET 10 or .NET 11 feature. We also inspect the proposed CA2027 analyzer separately from the runtime API, because a rule's merge date and its availability in an installed SDK are different facts.

## Name the three lifetimes before writing the race

01 / Watch the mechanism

### The wait ends before the work

![Three timelines distinguish the operation, caller wait and deadline timer. The operation continues after the wait times out.](https://devinside.cloud/content/images/2026/11/devinside/REL04-v1.svg)

The wait ends before the workThree timelines distinguish the operation, caller wait and deadline timer. The operation continues after the wait times out.REL04 / MOTIONThe wait ends before the workOperationCaller waitTimerDeadlineWork maystill finishResult laterLifecycle explanation, not an execution recording.

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

Static view. Play to see how the mechanism unfolds.

A timeout ends this caller's wait, not the underlying operation. The later result still needs an owner even though the deadline timer has already served its purpose.

Interpretation and evidence
- Name the operation owner.
- Name the waiting caller.
- Do not infer stopped work from a timeout.
implemented and tested lifecycle; diagram is conceptual

There are three objects of responsibility in a typical timeout wrapper. The operation represents work the application asked to perform. The wait represents a caller observing that operation. The timer supplies a signal that the caller's waiting interval has elapsed.

Sometimes these lifetimes end together. Often they do not. A shared cache refresh can continue after one HTTP request disconnects. A durable background job can remain accepted after the browser stops listening. A read from a cooperative local stream may stop promptly when its operation token is cancelled. Those are different contracts, even if all three callers use the word timeout.

Before choosing an API, write down who owns each lifetime. Can this caller request that the underlying work stop? Does another caller still need it? Is the operation capable of observing cancellation? If the result becomes available later, who observes it? If it fails, where does that failure go?

Consider a hypothetical document conversion. The UI may stop waiting after a short interactive deadline while the server continues a registered job. Cancelling the UI wait is appropriate; cancelling the shared job may be wrong. The user should receive an operation identifier or a truthful pending status rather than an invented assurance that nothing will happen.

Now consider a temporary local calculation created exclusively for one request. Continuing it after the caller leaves may waste resources. Here it can be sensible to request cancellation and wait for the calculation to release its resources. That waiting can exceed the original response deadline, which is another reason to distinguish the boundaries.

Cancellation in .NET is cooperative. A token communicates a request to participating code; it is not a mechanism for forcibly reversing arbitrary work. The operation decides where and how to observe that request. [Cancellation in managed threads](https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads?ref=devinside.cloud).

This ownership model is the foundation of the rest of the article. An API can simplify a wait race. It cannot decide whether abandoning, cancelling or finishing the operation is correct for the product.

## The short WhenAny idiom leaves a loser

02 / See the idea

### Every race leaves a responsibility

![When the operation wins, the delay needs cleanup; when the timeout wins, the original operation still needs an owner.](https://devinside.cloud/content/images/2026/11/devinside/REL04-v2.svg)

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

The same naive wrapper has two different unfinished obligations. Successful operation completion leaves its delay pending, while timeout leaves the operation capable of later success or failure.

Interpretation and evidence
- Observe the winning task.
- Cancel the obsolete delay.
- Own the operation after timeout.
implemented and tested lifecycle; diagram is conceptual

Here is the shape of the naive wrapper in the laboratory. The cleanup token is supplied by the fixture so the experiment can cancel leftover delays later; the wrapper itself does not cancel its delay when the operation wins.

```csharp
public static async Task<int> Naive(Task<int> operation, TimeSpan timeout,
    TimeProvider clock, CancellationToken cleanup)
{
    Task delay = Task.Delay(timeout, clock, cleanup);
    Task winner = await Task.WhenAny(operation, delay);
    if (winner != operation) throw new TimeoutException();
    return await operation;
}

```

When the operation completes first, the wrapper returns its result. The delay still has its own lifetime. `WhenAny` selecting a winner does not cancel the other input. The fixture later cancels its cleanup source; ordinary copied versions of this idiom often have no corresponding owner.

When the delay completes first, the wrapper throws. The operation remains exactly where its own execution has reached. Throwing an exception from the wrapper does not send a cancellation signal to work that never received a relevant token. It also cannot undo an external effect already committed.

The final `await operation` is essential. `WhenAny` returns a task identifying the completed input, and its outer completion is not proof that the selected operation succeeded. A faulted operation can win. You still need to observe that task's result or exception. The laboratory includes a direct check in which a faulted task wins the race and the exception is observed only when the winning task is awaited. [Task.WhenAny documentation](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.whenany?view=net-10.0&ref=devinside.cloud).

This means a wrapper that returns success whenever the operation wins has two defects, not one. It can leave a timer running and silently lose the operation's failure. Those problems deserve separate tests because fixing one does not fix the other.

There is also a subtle cancellation problem in the naive form. If the delay uses a token and that token is cancelled, the delay completes in a cancelled state. Merely comparing the winner with the operation can label that event a timeout. Our naive wrapper is not presented as a general cancellation-aware API. Its token is an experiment cleanup mechanism, and the measurements do not cancel it during the successful operation path.

## Cleaning the delay does not cancel the operation

The explicit race can be made more careful. The laboratory's `CleanRace` owns a linked cancellation source specifically for the delay and cancels it in a `finally` block. It then observes the delay's cancellation before releasing the source.

```csharp
public static async Task<int> CleanRace(Task<int> operation, TimeSpan timeout,
    TimeProvider clock, CancellationToken waitCancellation)
{
    using var delayCancellation =
        CancellationTokenSource.CreateLinkedTokenSource(waitCancellation);
    Task delay = Task.Delay(timeout, clock, delayCancellation.Token);
    try
    {
        Task winner = await Task.WhenAny(operation, delay);
        waitCancellation.ThrowIfCancellationRequested();
        if (winner == operation) return await operation;
        await delay;
        throw new TimeoutException();
    }
    finally
    {
        delayCancellation.Cancel();
        try { await delay; }
        catch (OperationCanceledException)
            when (delayCancellation.IsCancellationRequested) { }
    }
}

```

This is an application policy, not a universal drop-in replacement for `WaitAsync`. In particular, it explicitly checks the waiting token before returning an operation result. If the result is already available and the token is already cancelled, that policy can differ from the built-in fast path. We will test that difference rather than assume equivalence because both methods have timeout-shaped names.

The separate source matters. Cancelling a delay is a cleanup action on the observer's race. It should not automatically cancel a shared operation. Reusing an operation token source for both roles would couple lifetimes that may need to remain independent.

Disposal is not a substitute for cancellation. Disposing a `CancellationTokenSource` releases resources associated with the source; it does not communicate a cancellation request to consumers of its token. Code that uses a `using` statement and assumes it has therefore stopped a pending delay has skipped a semantic step. [CancellationTokenSource.Dispose documentation](https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtokensource.dispose?view=net-10.0&ref=devinside.cloud).

The explicit implementation has more machinery than the naive one: a linked source, a cancellation action and a cleanup await. Some of that cost is the work the naive version omitted. Comparing only the time to return would be misleading if we forget that one implementation has completed its responsibility and the other has handed unfinished work to the fixture.

This version also demonstrates why cleanup belongs on the fault path. If the operation wins by throwing, the delay must still be stopped. Placing cancellation only after a successful result would make exception-heavy traffic retain more timers than ordinary successful traffic.

## WaitAsync expresses a wait boundary directly

The built-in form makes the intended operation much clearer:

```csharp
public static Task<int> BuiltIn(Task<int> operation, TimeSpan timeout,
    TimeProvider clock, CancellationToken waitCancellation) =>
    operation.WaitAsync(timeout, clock, waitCancellation);

```

The returned task represents waiting until the source operation completes, the waiting token is cancelled or the timeout expires. The token in this overload belongs to the wait. It is not automatically forwarded into the already-created operation. [Task.WaitAsync documentation](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.waitasync?view=net-10.0&ref=devinside.cloud).

That makes `WaitAsync` a useful expression of a caller deadline, but it does not absolve the caller from owning the source task. If the wait times out, an owner still needs to decide whether to cancel, observe, register or deliberately retain the original work.

The runtime's implementation handles the timer and completion-registration race internally. The exact source checked for this article also shows a completed-task fast path before the cancellation check. In the laboratory, an already-completed operation returns its value through `WaitAsync` even when the supplied waiting token is cancelled. The custom race above instead gives its explicit cancellation check priority. [Task implementation at the .NET 10 release tag](https://raw.githubusercontent.com/dotnet/runtime/v10.0.0/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs?ref=devinside.cloud).

This is not an invitation to build product behavior around undocumented scheduling coincidences. It is a warning that wrappers can define different boundary policies. If the caller requires cancellation to take precedence over an available result, that requirement should be explicit and tested. If the caller accepts a result already available when it checks, the built-in behavior may be exactly right.

Nor should a code review describe `WaitAsync` as a feature introduced for this article's runtime. The .NET 6 release source already contains timeout-based `WaitAsync` APIs. The particular `TimeProvider` overload used in this laboratory is checked against the selected environment, but that does not make the entire concept new. [Task source at the .NET 6 release tag](https://raw.githubusercontent.com/dotnet/runtime/v6.0.0/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs?ref=devinside.cloud).

For the common requirement of limiting observation time, prefer expressing that requirement directly. Keep custom races for cases whose winner policy or composition actually differs, and document those differences.

## Put the token where the work can observe it

03 / See the idea

### Two tokens, two different boundaries

![A waiting token reaches WaitAsync; a separate operation token reaches the work. Only the latter requests cancellation of that work.](https://devinside.cloud/content/images/2026/11/devinside/REL04-v3.svg)

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

Passing a token to WaitAsync changes the wait contract. To request that the work stop, the owner must pass a token into the operation and verify its eventual completion.

Interpretation and evidence
- A waiting token is not forwarded automatically.
- Operation cancellation is cooperative.
- Draining can outlast the deadline.
implemented and tested lifecycle; diagram is conceptual

To request that an operation stop, supply its token when starting the operation. Adding a token only to a later wait cannot retrofit cancellation into code that has already begun.

The distinction is visible in a deliberately simple operation used by the test suite:

```csharp
bool cleaned = false;
async Task<int> Work(CancellationToken token)
{
    try
    {
        await Task.Delay(Timeout.InfiniteTimeSpan, token);
        return 1;
    }
    finally
    {
        cleaned = true;
    }
}

```

This operation is cooperative. It waits on something that observes the supplied token, and it records that its cleanup ran. The test starts it through an owner, fires the waiting timeout and verifies that the owner requests operation cancellation and waits for cleanup before returning the timeout failure.

The owner creates a separate operation cancellation source linked to the caller. It passes that token to `start`, then waits through `WaitAsync`. On a timeout or cancellation exception it cancels the operation source, awaits the original operation and records any later failure. The full implementation is in `Waiters.Owned`.

The important trade is that draining the operation can take longer than the original wait timeout. That is not a contradiction. The deadline described how long the caller would wait for ordinary success; cleanup is a subsequent responsibility. An API that promises a hard return bound cannot also promise to synchronously finish arbitrary uncooperative work.

The next test makes the operation ignore its token. Instead of starting uncontrolled background computation, it uses a `TaskCompletionSource<int>` whose completion belongs to the test. After the timeout, the owner remains incomplete because it is still draining the operation. The test then supplies a late failure and verifies that the owner observes it while preserving the original timeout as the caller-facing exception.

This is a small but important counterexample. Cancellation requested is a state we can establish. Operation stopped is a later state requiring evidence. If the operation is a remote write, even a cancelled local task may not establish whether the remote system committed the effect. That question requires the destination's idempotency or reconciliation contract.

The sample owner is intentionally narrow. It treats a timeout or cancellation exception from the observed path as a reason to cancel and drain. It does not separately classify a `TimeoutException` thrown by the operation itself. A production boundary needing that distinction should record where the deadline originated instead of guessing from the exception type alone. The cleanup error callback must also be nonthrowing if the original failure is to remain authoritative.

## Someone must observe the result after the caller leaves

There are legitimate reasons not to cancel the underlying operation. Several callers may share it. The work may already be registered as a durable job. Completing a small piece of maintenance may be more useful than abandoning it.

In those cases, the application needs an owner whose lifetime exceeds the departing caller. That owner can await the result, record completion and route failures to a meaningful diagnostic boundary. Returning a task reference to nobody is not ownership simply because the garbage collector may eventually encounter it.

The test suite demonstrates late observation without pretending to implement a production job manager. It starts a controlled source task, times out the wait, checks that the source task remains incomplete, then completes the source with a known exception. Awaiting the original task confirms that the exception remains observable after the waiting wrapper has failed.

Do not use this as an argument to catch and discard every late exception. A failure can explain why an accepted background job never produced its result. The observer should preserve operation identity and the distinction between a timeout experienced by one caller and the operation's eventual terminal state.

For a shared refresh, the result might be stored for the next caller. For an accepted document job, it might update durable state. For a disposable request-owned calculation, the right choice may be cancellation and draining. The same `Task` APIs can participate in all three designs, but the product consequences differ.

There is a corresponding retry question. A caller receiving `TimeoutException` knows that its waiting contract expired. It does not know that nothing happened. Blindly starting another external action can duplicate work that the first operation is about to commit. A retry should preserve the operation identity or consult authoritative state when effects matter.

This is the point where a small asynchronous-code discussion becomes architecture. Timers control when we stop observing. Only the operation contract can explain what continuing, repeating or abandoning the work means.

## Test the order instead of sleeping and hoping

04 / A concrete example

### Control the order, then check the aftermath

![Controlled test order maps to expected state, including a late fault and an operation that ignores cancellation.](https://devinside.cloud/content/images/2026/11/devinside/REL04-v4.svg)

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

Manual one-shot callbacks trigger timeout without relying on a sleep. A TaskCompletionSource then makes the operation succeed or fail, allowing the test to inspect what remains after the wait ended.

Interpretation and evidence
- Trigger the intended order explicitly.
- Settle every controlled operation.
- Do not let a watchdog count as the expected timeout.
implemented and tested lifecycle; diagram is conceptual

A test that sleeps for ten milliseconds and expects a race to finish in a particular order is partly testing the machine's scheduling conditions. Under load, it may fail despite correct code or pass without exercising the intended boundary.

The laboratory uses a custom `TrackingTimeProvider`. In manual mode, it creates tracked one-shot timer objects but does not schedule them with the operating system. The test explicitly invokes `FireAll` after it has established the required state. `TaskCompletionSource` independently controls when the operation completes.

This is not a general virtual clock. It does not claim to simulate arbitrary elapsed time, periodic scheduling, rescheduling or production timer concurrency. Its supported contract is deliberately restricted to positive one-shot timers, the exact form used by these tests. Unsupported configurations fail visibly.

The extension point is `TimeProvider.CreateTimer`, which returns an `ITimer`. In system mode our wrapper delegates to the real provider and records creation and disposal. In manual mode it retains the callback for deterministic triggering. The official contract is broader than the fixture, so the restriction belongs in the fixture documentation. [TimeProvider.CreateTimer documentation](https://learn.microsoft.com/en-us/dotnet/api/system.timeprovider.createtimer?view=net-10.0&ref=devinside.cloud).

The 49 checks cover operation-first, timeout-first, cancellation of the wait, early operation failure, late operation failure, cleanup and the completed-operation/cancelled-token precedence difference. Every test that abandons a source wait still settles the controlled source operation before the test ends. Providers and cancellation sources are disposed by their owners.

There is a five-second wall-clock watchdog around expected completions to avoid a broken test hanging indefinitely. It is not evidence of the intended timeout. An expected exception is accepted only when the task under test is itself complete; the watchdog's timeout cannot satisfy a test expecting `TimeoutException`. Otherwise a deadlock could be misreported as success.

The timer count is also precise about what it measures. `Outstanding` counts tracked timer objects not yet disposed, not bytes in the GC heap or a count of kernel handles. A disposed wrapper can remain referenced by the measurement list until the batch ends. That is deliberate instrumentation, and it means the list's retained objects must not be presented as leaked application memory.

## CA2027 is a clue, not proof of ownership

The proposed analyzer CA2027 addresses a recognizable form of the problem: a non-cancellable `Task.Delay` passed into `Task.WhenAny`. The official pull request was merged on 28 October 2025\. That establishes a source-development event. It does not establish the first SDK that shipped the rule or prove that this machine's compiler enables it. [Official SDK pull request 51452](https://github.com/dotnet/sdk/pull/51452?ref=devinside.cloud).

For this article, a separate probe loaded the three analyzer assemblies shipped under SDK 10.0.400 and enumerated the supported diagnostics of their concrete analyzer types. The successful probe had no type-instantiation errors and found no CA2027 descriptor. The record includes the assembly hashes. Consequently, the article does not claim CA2027 protection for this build. Its first shipping SDK remains unestablished here.

The probe also compiled three small source shapes for investigation: a direct non-cancellable delay, a delay with a token, and a delay stored in a local variable before `WhenAny`. With no matching analyzer in the inspected catalog, there was no CA2027 analysis to apply. An absence of diagnostic output alone would have been weaker evidence, because a present rule might be disabled or fail to match a particular shape.

Even with a matching rule, the ownership question remains larger than syntax. Supplying a token does not prove that someone cancels it after the operation wins. The naive laboratory wrapper has a cancellation token and still leaves its timer outstanding until the fixture explicitly cleans it up. A helper can also hide a delay behind a method call, beyond a simple syntactic pattern.

The useful role of an analyzer is to bring a suspicious construct to review. It cannot decide whether the operation should continue for other callers, whether a late write committed remotely, or whether cleanup is allowed to exceed the request deadline. Those require the lifetime model and behavioral tests.

If a team adopts the rule later, it should verify the exact installed analyzer version and test a known triggering sample. Treat analyzer rollout as a separate change from replacing a timeout wrapper. A green build is not a proof that all losing tasks have owners.

## Measure returned work and leftover resources separately

05 / See the idea

### Count what remains after returning

![Measured timer counts show 128 outstanding timers after naive completion and zero after cleaned or WaitAsync completion in both tested states.](https://devinside.cloud/content/images/2026/11/devinside/REL04-v5.svg)

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

Each batch ran 128 successful operations. The naive variant returned while all timers remained outstanding; fixture cleanup then canceled every one, and no variant left an outstanding timer after that cleanup.

Interpretation and evidence
- Before-cleanup counts expose deferred work.
- Every batch ended with zero outstanding timers.
- Tracked timers are not a retained-heap measurement.
measured; benchmark-results.json; 2026-11-12

The benchmark compares three implementations: the naive race, the explicit cleaned race and `WaitAsync`. It uses two states. In the first, the operation is already complete when the wrapper receives it. In the second, the wrapper attaches to a pending task and the fixture completes that task immediately afterward.

These are controlled completion paths, not simulations of a remote service's latency distribution. The pending task permits inline continuations in this experiment. A task produced by another library can schedule its continuations differently, which can change both timing and where allocations occur.

Each measured batch contains 128 operations. Timers use a one-minute due time so successful operation completion wins under the bounded run, but they are not left alive for a minute after the sample. A fixture-owned cancellation source stops the naive delays after counts are recorded. The cleaned implementation stops each delay internally; `WaitAsync` manages its own waiting resources.

The timing boundary includes each wrapper's normal completion path and its internal cleanup. It excludes the fixture cleanup that rescues the naive implementation. This is intentional: the report must show both the time to return and the resources left behind. It would be wrong to recommend the naive implementation as cheaper while omitting the fact that another owner still has work to do.

The real-time provider is instrumented. It wraps actual one-shot timers, tracks them in a list and counts disposal. That instrumentation has cost. The benchmark therefore compares these implementations under the same measuring arrangement, not raw framework throughput without observation. The allocation numbers include task setup and tracking objects within the timed loop.

Nine measured samples follow two warm-up batches for each configuration. The order is reversed on alternating samples. This is a deliberately small custom stopwatch experiment, not BenchmarkDotNet, a long steady-state service test or a statistical population study. Input state, code paths and cleanup are more valuable here than a spurious claim of nanosecond precision.

The exclusive local run completed on 12 November 2026 at 13:45 UTC on .NET 10.0.15, X64, Windows build 20348, with workstation GC. The host exposed 64 logical processors. Other task benchmarks were not run concurrently, but this was still a shared development host, not a dedicated performance machine.

Every configured case is shown below. Times are medians of nine samples, expressed in nanoseconds per completed operation. The timer columns describe a batch of 128 operations, not a per-operation count. Allocation is managed bytes per operation on the measured thread.

| Operation state       | Wrapper   | Median ns/op | Allocated B/op | Timers created | Outstanding before fixture cleanup | After cleanup |
| --------------------- | --------- | ------------ | -------------- | -------------- | ---------------------------------- | ------------- |
| already-complete      | naive     | 957.0        | 641.375        | 128            | 128                                | 0             |
| already-complete      | clean     | 6381.2       | 1330.000       | 128            | 0                                  | 0             |
| already-complete      | waitasync | 68.8         | 72.000         | 0              | 0                                  | 0             |
| complete-after-attach | naive     | 1560.2       | 777.375        | 128            | 128                                | 0             |
| complete-after-attach | clean     | 7460.2       | 1506.000       | 128            | 0                                  | 0             |
| complete-after-attach | waitasync | 885.9        | 458.000        | 128            | 0                                  | 0             |

The most important difference is not the smallest time. The naive wrapper left all 128 timers outstanding in both completion states. The fixture then cancelled them and asserted zero outstanding resources before proceeding. The cleaned race and `WaitAsync` left none outstanding when their measured completion paths finished.

For an already-completed operation, `WaitAsync` created no timer at all. Its observed 72 bytes per operation belong to the measured setup and call path, which includes creating the completed integer task in the harness; they should not be advertised as an intrinsic allocation cost of the `WaitAsync` fast path. The pending-operation case did create one tracked timer per operation and disposed it after completion.

The explicit cleaned race was slower and allocated more than the naive race in these successful paths. That observation is included rather than hidden: it performed cancellation and cleanup work that the naive wrapper deferred. The custom linked source, observed cancellation and instrumentation are plausible contributors, but this run does not isolate their individual costs. It would be misleading to interpret its ranking as proof that leaving resources behind is more efficient overall.

Sample ranges also matter. The already-completed `WaitAsync` path ranged from 64.8 to 114.1 ns/op, while the pending path ranged from 853.1 to 1,035.2 ns/op. The cleaned race ranged from 6,286.7 to 6,778.1 ns/op for completed input and from 7,322.7 to 7,502.3 ns/op after attachment. These are small, within-process batches. There is no confidence interval or claim that another machine will reproduce the same ratios.

This experiment measured successful completion before the timer's due time. Timeout and cancellation semantics were exercised in the separate deterministic correctness suite, not given invented performance numbers. Retained heap, native handles, timer accuracy under load and network-service latency were not measured. The tracked resource count establishes the behavior of this instrumented boundary; it does not measure every runtime object retained behind it.

The evidence supports a bounded decision: for an ordinary wait timeout in this fixture, the built-in operation expressed the intended lifetime more directly and avoided the naive leftover timer. A different ownership requirement still needs application logic. No measured row changes the fact that timing out the wait leaves the original work under its own contract.

## Choose the boundary that tells the truth

For a caller that owns only its observation time, `WaitAsync` is usually the clearest expression of the requirement. Keep a separate owner for the original operation. Report a timeout as a waiting outcome and retain a way to learn what happened afterward when the result matters.

For request-owned cooperative work, pass a token into the operation itself. Decide whether the caller also drains it, whether a supervisor takes over cleanup, and what happens if it ignores cancellation. Do not advertise an unconditional hard timeout for a wrapper that deliberately waits for arbitrary work to settle.

For a custom `WhenAny` race, define the winner policy, observe the winner's result and account for every loser. If a delay is only a deadline signal, cancel it when it no longer serves a purpose. If another task is a shared operation, cancelling it may violate somebody else's contract. Cleanup is an ownership decision before it is a line of code.

The code and evidence are available beside this article. From `lab`, run correctness before considering a measurement:

```powershell
dotnet run -c Release -- test
dotnet run -c Release --no-build -- bench ../benchmark-results.json

```

The local project pins SDK 10.0.400 and runtime 10.0.15 without changing global configuration. The measurement command belongs in a quiet, exclusive task-measurement window. It asserts that no tracked timer remains outstanding after each batch and disposes the provider even if a failure interrupts the experiment.

No benchmark can decide whether your operation should stop when a caller leaves. That decision depends on what the operation means. What the laboratory can establish is narrower and useful: a timeout can leave work running, a token can target the wrong lifetime, a losing delay can outlive a successful request, and explicit ownership makes each consequence observable.

The most important correction is therefore a sentence we should be able to say accurately: this caller stopped waiting; this timer was cleaned up; this operation was cancelled and has actually finished. When those are three different events, the code, telemetry and user interface should allow them to remain different events.

## Source code and execution evidence

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