---
title: "What happens to a method that awaits forever?"
description: "What happens if you `await` something that never finishes? Does the method sit in memory until the heat death of the universe (or all the other possible scenarios)? Does it leak? Let's find out, and then do some things you should definitely 100% legit do in production."
updated: 2026-11-08
tags: ["C#", ".NET", "async"]
canonical: "https://steven-giesel.com/blogPost/bb0a8fdf-5cf6-481a-9d0d-7dc7578357a4/what-happens-to-a-method-that-awaits-forever"
---

# What happens to a method that awaits forever?

What happens if you `await` something that never finishes? Does the method sit in memory until the heat death of the universe (or all the other possible scenarios)? Does it leak? Let's find out, and then do some things you should definitely 100% legit do in production.

## Setup

We need a method that waits forever, and a way to see whether it's still alive. A TaskCompletionSource that nobody ever completes handles the first part:

```csharp
static async Task AwaitForever(Tracker tracker)
{
    var tcs = new TaskCompletionSource();
    try
    {
        await tcs.Task; // spoiler: nope
        Console.WriteLine($"Resumed {tracker.Name}");
    }
    finally
    {
        Console.WriteLine($"finally for {tracker.Name}");
    }
}
```

For the second part, Tracker is a small class with a finalizer that prints when the GC eats it:
```csharp
sealed class Tracker(string name)
{
    public string Name { get; } = name;
    ~Tracker() => Console.WriteLine($"Tracker '{Name}' was collected");
}
```

I am aware I wrote a blog post [demonizing finalizers](https://steven-giesel.com/blogPost/3b55d5ac-f62c-4b86-bfa3-62670f614761/stop-using-finalizers-in-c) - but here it is 100% justified and we are in that 0.1% case I was talking about!

Now we fire and forget, and force the GC to collect:
```csharp
_ = AwaitForever(new Tracker("fire-and-forget"));

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
```
<sup>Again: Siper justified stuff</sup>

This gives ous:
```csharp
Tracker 'fire-and-forget' was collected
Done.
```

Hmm- The method was collected, but no `finally` was called! Also `using` would not have helped here, as it is just some sprinkled magic around calling `finally`. But `finally` comes after the `return` statement, which would never occurred here! (Not as in literal return statement, more the stackframe pointer moves).

## Who holds whom?

A method that awaits isn't "waiting" anywhere. There's no thread blocked on it. When the compiler builds the async state machine, await means: "Hey task, here's my continuation. Call me when you're done." Then the method returns.

So the reference graph looks like this:

```no-class
tcs  ->  tcs.Task  ->  continuation  ->  state machine  ->  hoisted locals (tracker)
```

The arrows point one way only. The state machine knows about the task, and the task knows about the continuation. But nothing outside points into this little island: no stack frame, no static, no thread. To the GC, that's garbage like any other. The whole chain gets collected, and since collecting something isn't the same as returning from it, the finally never runs.

## Do proper engineering now - partially

Let's keep the returned `Task` this time:
```csharp
var task = AwaitForever(new Tracker("kept task"));
Collect();
Console.WriteLine(task.Status); // WaitingForActivation
GC.KeepAlive(task);
```

Nothing gets collected. In modern .NET the returned `Task` is the box that holds the state machine. Hold the task and you hold the whole method, including every local that's still needed after the await. 

Another fun one, if you wanna leak a bit memory in the same style:

```csharp
static class Gate
{
    public static readonly SemaphoreSlim Semaphore = new(0); // nobody ever releases
}

static async Task ForeverSemaphore()
{
    var buffer = new byte[100];
    await Gate.Semaphore.WaitAsync();
    GC.KeepAlive(buffer);
}
```

The semaphore keeps a list of waiters, the waiters point to the continuations, and the continuations point to the state machines. If you call that a few times (as in million), you can compare the same with `tcs`:

```no-class
tcs:       0 MB still alive after 1,000,000 calls
semaphore: 305 MB still alive after 1,000,000 calls
````

Same "idea" but a little different outcome.

## Let's wait forever or 49.7 days!

I wrote some time ago [*"Task.Delay fails if you wait longer than 49.7 days"*](https://steven-giesel.com/blogPost/c3bdad0d-f9eb-4dcf-b650-98ec0f5dd4d4/taskdelay-fails-if-you-wait-longer-than-497-days). So let's just wait "reasonable":

```csharp
await Task.Delay(Timeout.InfiniteTimeSpan); // A
await Task.Delay(TimeSpan.FromDays(1));     // B
```

Guess the output
```csharp
A: Tracker 'infinite' was collected
B: (silence)
```

While A gets collected B sits there for a while. If you wait `Timeout.InfiniteTimeSpan` then .NET is a bit sneaky! `Task.Delay` is using a `Timer` (as in one shared timer across all your calls), but if you are unreasonable and wanna wait forever, .NET doesn't care and doesn't "enqueue" anything to be awaited for. So it kind of behaves like a sync noop with a little bit overhead ;)

## Top N things you should do in production: Necromancer stuff

We know the state machine is only reachable through the `TaskCompletionSource`. We also know finalizers run after something has become unreachable. What if a finalizer completes the TCS?

```csharp
sealed class Necromancer(TaskCompletionSource tcs)
{
    ~Necromancer() => tcs.SetResult();
}

static async Task Zombie()
{
    var tcs = new TaskCompletionSource();
    var necromancer = new Necromancer(tcs);
    try
    {
        await tcs.Task;
        Console.WriteLine("Braaaains!");
    }
    finally
    {
        GC.KeepAlive(necromancer);
    }
}
```

Now we do complete the task eventually and therefore we will run into the a) the Console WriteLine and b) the finally block!

## .NET 11 runtime async!

While I was talking about state machine, that is only true mainly until .NET 10. With .NET 11 you can opt-in into runtime async. 

```xml
<Features>$(Features);runtime-async=on</Features>
```

So the runtime does the suspension and recapturing of the context into the continuation. If you try to same stuff as above. Well nothing changes at all. From an abstract point of view, the behaviour is the same. It did not change so much. More: The implementation on how to achieve that is different (dare I say better/more clever?).