Have you ever clicked Stop and watched your code keep running anyway?
That’s not a bug. It’s a loop that was never given a way to check. It doesn’t ignore you on purpose — it simply never asked, so it never looks. Fixing this doesn’t require a redesign. It takes one object, threaded through the places that were already running: CancellationTokenSource.
Two buttons, one loop each, ten ticks apart. One of them listens for a cancel signal. The other doesn’t.
private async UniTask RunUncancellableLoop()
{
for (int i = 1; i <= 10; i++)
{
await UniTask.Delay(500);
Log($"Uncancellable tick {i}/10");
}
Log("Uncancellable loop finished naturally.");
}
private async UniTask RunCancellableLoop()
{
cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
try
{
for (int i = 1; i <= 10; i++)
{
token.ThrowIfCancellationRequested();
await UniTask.Delay(500, cancellationToken: token);
Log($"Cancellable tick {i}/10");
}
Log("Cancellable loop finished naturally.");
}
catch (OperationCanceledException)
{
Log("Cancellable loop was cancelled early.");
}
}
Same shape. Same delay. The only difference is two lines: token.ThrowIfCancellationRequested(), and passing token into the delay itself.
Uncancellable loop started. Try clicking Cancel — nothing happens.
Uncancellable tick 1/10
Uncancellable tick 2/10
...
Uncancellable tick 10/10
Uncancellable loop finished naturally.
Cancellable loop started.
Cancellable tick 1/10
Cancellable tick 2/10
Cancellable tick 3/10
Cancellable tick 4/10
Cancellable loop was cancelled early.
Both loops got the same click on the same Cancel button. Only one of them noticed.
CancellationTokenSource isn’t magic — it’s closer to a shared flag with a name. Calling cts.Cancel() flips that flag. Nothing forces the loop to check it. That’s the part that surprised me most: cancellation in C# is cooperative, not preemptive. The running code has to actively ask “has someone cancelled me?” — and if it never asks, Cancel() does nothing but sit there, technically fired, practically ignored.
That’s exactly what happens in RunUncancellableLoop. It has no CancellationToken parameter anywhere. There’s nothing to check, so there’s nothing to stop it.
RunCancellableLoop checks in two places, and they’re not redundant — they catch different moments:
token.ThrowIfCancellationRequested() catches a cancellation that happened before this tick starts. await UniTask.Delay(500, cancellationToken: token) catches one that happens during the 500ms wait itself — it doesn’t wait out the full delay and check afterward, it interrupts the wait immediately.Miss the second one — pass a plain UniTask.Delay(500) without the token — and you get a strange in-between state: the loop still technically responds to cancellation, but only up to half a second late, at the top of the next iteration. Worth testing deliberately: swap it out, click Cancel, and watch the delay finish anyway before the loop notices.
CancellationTokenSource and CancellationToken are not the same thing, and mixing them up is a common first mistake. The Source is the one with .Cancel() — think of it as the switch. The Token is the read-only thing you actually hand down into async methods — think of it as the wire carrying the switch’s state. You never call .Cancel() on a token; you only ever read it.
cts = new CancellationTokenSource(); // the switch
CancellationToken token = cts.Token; // the wire you actually pass around
Everything downstream — Delay, ReceiveAsync, your own loops — takes the token, never the source. The source stays with whoever’s allowed to flip the switch.
One important clarification: calling Cancel() updates IsCancellationRequested immediately. There is no delay in signaling the token itself. The delay is only on the other side: your running code needs to reach a cancellation-aware operation or explicitly check the token before it can react. The gap is between cancellation being signaled and cancellation being noticed.
Build both loops, wire up one Cancel button shared between them, and click Cancel mid-run on each. Then try breaking it on purpose: remove the token from Delay in the cancellable version and see how much later cancellation actually takes effect. That gap — between “cancelled” and “noticed” — is the entire lesson this pattern exists to close.

Full source for both loops — CancelDemoController.cs, scene setup instructions — is on GitHub. Clone it, hit both buttons, then make the change described above and watch the timing shift.