Checking IsCancellationRequested works, but it only works if something is actively looking. A loop mid-await will notice eventually. But what about code that isn’t looping at all — a resource that just needs to clean itself up the moment cancellation happens, with nobody polling it?
That’s what Register() is for. Instead of asking “has this been cancelled yet?” over and over, you ask once: “when this gets cancelled, run this.” Then you stop thinking about it.
registration = token.Register(() =>
{
Log("Register callback fired — cleanup ran automatically.");
});
This line runs once, at setup. The callback inside doesn’t run yet. It runs later, on its own, the instant cts.Cancel() is called — whether or not anything else in the code is checking the token at that moment.
I ran the demo, called Cancel mid-loop, and logged everything:
Task started. Click Cancel to see Register fire on its own.
Tick 1/10
Tick 2/10
Tick 3/10
Tick 4/10
Register callback fired — cleanup ran automatically.
Task loop exited via exception.
In this example, the Register callback consistently fires before the loop reaches its exception handler. That’s not luck.

Cancel() does two things, in order:
IsCancellationRequested to true. Cancel() even returns.The loop, meanwhile, is sitting inside await UniTask.Delay(500, cancellationToken: token). It doesn’t get interrupted mid-wait by magic — it only discovers the cancellation the next time the await mechanism checks in, which happens on roughly the same tick but isn’t instantaneous the way a direct function call is.
So the ordering isn’t a coincidence: Register runs as a direct, synchronous consequence of Cancel(). The loop’s exception is a downstream effect that has to wait its turn.
Look at Register() next to something from an earlier post:
// CancellationToken
registration = token.Register(() => Log("Cancelled."));
// ReactiveProperty, from the health bar
model.CurrentHealth
.Subscribe(hp => view.SetSliderValue(hp))
.AddTo(this);
Same shape. Both say: “when this event happens, call this code — and I don’t want to check for it myself.” Both return something disposable (IDisposable for Register, the subscription for Subscribe) that has to be cleaned up or the listener outlives its usefulness.
The real difference is how many times the event can fire. ReactiveProperty can broadcast every time a value changes — health can drop from 100 to 90 to 80, over and over, and every Subscribe hears each one. A CancellationTokenSource can only ever be cancelled once. Register doesn’t hand you a stream; it hands you a single notification, guaranteed to fire at most one time, ever, for that token.
Same pattern, narrower scope. Cancellation is Observer pattern for an event that can only happen once.
The health bar version reacts to gameplay state. Register tends to show up around cleanup: closing a socket, releasing a file handle, resetting UI state — anything that needs to happen the moment something is torn down, regardless of what else in the code happens to be checking at the time.
socketConnection.Token.Register(() =>
{
if (socket.State == WebSocketState.Open)
{
socket.Abort();
}
});
This is intentionally an abrupt cleanup example — a real shutdown would typically coordinate CloseAsync with the lifetime of the receive/send loops, rather than aborting the connection outright.
This doesn’t wait for a receive loop to notice cancellation and exit gracefully. It fires the instant cancellation happens, independent of whatever else is running.
Register() returns an IDisposable for the same reason Subscribe() does — if you never dispose it, the callback stays wired to the token, and depending on what it’s capturing, that can be a real memory leak, not just a style nitpick. The finally { registration.Dispose(); } in the demo isn’t decoration. Skip it, and every RunTask() call adds one more permanently-registered callback that never gets released.
Add a second Register() call on the same token, with a different message. Cancel, and see which callback fires first. Then ask yourself: should production code rely on that ordering? Then try registering after the token’s already been cancelled. (Spoiler: it fires immediately, synchronously, right there in the Register() call itself — which is worth confirming rather than trusting.)
Calling Register on an already-cancelled token...
Late Register fired!
Register() call has returned.
The callback fires inside the Register() call itself, before it even returns to your code. There’s no waiting-room for late arrivals — Register() always reflects the token’s current state, immediately, whether you called it before or after cancellation.

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.