Programming

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

A

Admin User

Author

Jun 26, 2026
4 min read
12 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

Code Isn't Engineering Until You Stop Thinking It Is
Programming Jul 17

Code Isn't Engineering Until You Stop Thinking It Is

Someone asked me last week what I actually do for a living. Not in the polite "oh that's nice" way—genuinely curious. I said "I'm a developer" and watched their face go blank. They nodded like I'd said "I work in an office." It hit me that I couldn't explain my job any better tha...

PHP Forms Aren't Broken—Your Expectations Are
Programming Jul 16

PHP Forms Aren't Broken—Your Expectations Are

I spent two hours last week debugging a form that "wasn't working." The client said data wasn't being saved. I pulled up the network tab, saw the POST request going out clean, checked the database, and found... nothing. Then I looked at the form's action attribute. It was pointin...