Programming

Why I Finally Stop Ignoring Async Cleanup Code (And Why It Bit Me)

A

Admin User

Author

Jun 26, 2026
4 min read
17 views
Why I Finally Stop Ignoring Async Cleanup Code (And Why It Bit Me)

Last week, I spent three hours debugging a timeout that wasn't actually timing out. I had a Unity-style async operation running, set up CancelAfter() to enforce a 10-second limit, and the thing just... ignored it. No exceptions, no warnings—just silent failure. When I finally traced through the code, I realized I'd disposed the CancellationTokenSource three times without ever nulling the reference. That's when it hit me: I've been writing careless cleanup code for years because it looked fine and didn't immediately explode.

This is the kind of bug that exists in the gap between "it works most of the time" and "it definitely doesn't work when you need it." I've borrowed code from projects, copy-pasted patterns without thinking, and moved on because nothing crashed in my immediate test case. But async operations are inherently unpredictable, and when you layer in cancellation, disposal, and potential re-entrancy (like restarting an operation mid-flight), that carelessness adds up fast.

The Confusion That's Hiding in Plain Sight

Here's what I didn't understand clearly until I read deeper on this: Cancel() and Dispose() are completely different operations pretending to be similar. I knew that intellectually, but I treated them like a package deal—just call both and move on.

Cancel() is a message. It tells anything holding a CancellationToken to please stop what it's doing. It's polite and cooperative. If nobody's listening, nothing happens. If the operation doesn't check the token, it just keeps running.

Dispose() is cleanup. It releases internal resources—timers, event registrations, linked token machinery. This is where the actual memory and system handles get freed. You can call Cancel() without Dispose(), but you'll leave resources hanging around. And if something kept a reference to that CancellationTokenSource, you could theoretically call Cancel() on a disposed object and hit an ObjectDisposedException.

The trap I kept falling into: my null-coalescing checks _cts?.Cancel() didn't actually protect me from the disposed case. The object still exists after disposal—it's just dead inside.

The Real Problem With Field-Based CTS Management

Managing a CancellationTokenSource as a class field without a helper abstraction creates this messy state machine in your head. Every time you need to restart an operation, you're thinking: "Do I dispose the old one first? Do I null it? What if OnDestroy() fires while I'm mid-replacement?"

I've seen production code that looks like this everywhere:

private CancellationTokenSource? _cts;

public void RestartOperation()
{
    _cts?.Cancel();
    _cts?.Dispose();
    _cts = null;  // This fixes it, but it's boilerplate
    
    _cts = new CancellationTokenSource();
    _cts.CancelAfter(TimeSpan.FromSeconds(10));
    // ... start async work
}

This works, but it's annoying to write. And annoying patterns get copy-pasted wrong. Someone forgets the null assignment. Someone adds a linked token without disposing both sources. Someone calls the cleanup method from two different code paths without thinking through the state.

What A Helper Class Actually Buys You

The concept here is straightforward but powerful: instead of exposing the CancellationTokenSource as a field, you wrap it in a class that owns the disposal semantics. The wrapper handles the state transitions—creation, cancellation, safe disposal—without exposing the sharp edges.

This means your business logic doesn't have to think about whether something's been disposed. You call "cancel this operation," and the helper figures out if it needs to actually cancel, dispose, create a new one, or do nothing.

public class CancellationHelper
{
    private CancellationTokenSource? _cts;

    public CancellationToken Token
    {
        get
        {
            _cts ??= new CancellationTokenSource();
            return _cts.Token;
        }
    }

    public void Cancel()
    {
        if (_cts != null && !_cts.IsCancellationRequested)
        {
            _cts.Cancel();
        }
    }

    public void SetTimeout(TimeSpan duration)
    {
        Reset();
        _cts = new CancellationTokenSource();
        _cts.CancelAfter(duration);
    }

    public void Reset()
    {
        _cts?.Dispose();
        _cts = null;
    }
}

Now your game code is boring and obvious:

private CancellationHelper _helper = new();

public async void Reload()
{
    _helper.Reset();
    _helper.SetTimeout(TimeSpan.FromSeconds(10));
    
    try
    {
        await LoadAsync(_helper.Token);
    }
    catch (OperationCanceledException)
    {
        // Handle gracefully
    }
}

My Take

I appreciate articles that point out these subtle land mines. The CancellationTokenSource API is well-designed for single-scope usage (where using is enough), but it gets messy fast in stateful code. A helper class isn't revolutionary—it's just good housekeeping.

What I'd add: think about whether you even need to keep the CTS in a field. If you're restarting operations frequently, maybe abstract that away entirely. Some game frameworks let you pass in a "stop request" delegate. Others use a higher-level cancellation model.

The lesson for me is stop assuming patterns "just work" and actually reason through the state transitions. This applies to so much code we write without thinking.

Source: This post was inspired by "Creating a Small Helper Class to Safely Handle CancellationTokenSource Cancel and Dispose" by Dev.to. Read the original article

Share this article

Written by Adil Sher

Full stack developer building high-traffic platforms, AI services, and custom web applications. Explore my portfolio, learn about my background, or get in touch.

Related Articles

JavaScript's Type Coercion Will Haunt You Until You Stop Fighting It
Programming Aug 2

JavaScript's Type Coercion Will Haunt You Until You Stop Fighting It

I remember the exact moment I stopped being angry at JavaScript. I was debugging a production bug at 2 AM, staring at a comparison that made absolutely no sense on the surface. The code was doing something impossible according to basic math. I wanted to blame the language. Then I...