C# Delegate Callbacks: Progress, Cancellation, Chains

C# callbacks on .NET 10: Action vs IProgress<T> (and why Progress<T> reorders output), CancellationToken, and what a throwing handler does to a chain.

Revised November 2026. This tutorial first appeared in Korean in 2021. We recompiled all of its examples on .NET 10.0.14 (C# 14). None of the four full examples compiled as written. The delegate was declared as MtDelegate but used as MyDelegate (error CS0246), Console was misspelled Comsole and Colsole (error CS0103), the generic example used 3.3 f with a space, and the declaration snippet delegate void typeA(void); is C, not C# (error CS1536). The snippet that assigned new typeA(funcB) to a typeB variable would also fail (error CS0123). After the fixes all examples printed what the post claimed. Below we keep the short delegate recap and spend the rest on what the original only hinted at: designing callbacks in real code.

This article is for developers who understand that a delegate can be passed to a method, and now have to design an API that calls back: report progress, allow cancellation, or notify several listeners. Short version: take an IProgress<T> for progress and a CancellationToken for cancellation, and remember two chain rules. An exception stops the rest of a chain, and a chain returns only the last value; GetInvocationList() fixes both. For delegate basics and the event keyword, see C# delegates and events. For Func/Action and their call cost, see Func and Action delegates.

The 2021 examples, fixed and re-run

A delegate type is declared with the signature of the methods it will refer to. The original listed four declarations in C style:

delegate void typeA(void);   →  error CS1536: Invalid parameter type 'void'
delegate void typeB(int);    →  error CS1001: Identifier expected

C# has no (void) parameter list; an empty list is (). And a delegate parameter needs a name: delegate void typeB(int value);. Assigning a method of the wrong shape is caught at compile time: typeA d = new typeA(funcB);, where funcB takes an int, gave error CS0123: No overload for 'funcB' matches delegate 'typeA'.

With MtDelegate renamed and Comsole corrected, the first example printed:

11 + 22 = 33
22 - 11 = 11

The callback example passes the operation into a Calculator method. Modern C# lets you skip new MyDelegate(...) and pass the method group directly, or a lambda:

Calculator(11, 22, Plus);              // 33
Calculator(33, 22, (a, b) => a - b);   // 11
Calculator(11, 22, (a, b) => a * b);   // 242

static void Calculator(int a, int b, Func<int, int, int> op) => Console.WriteLine(op(a, b));
static int Plus(int a, int b) => a + b;

The generic version, delegate T MyDelegate<T>(T a, T b), printed 33, 7.7 and 12.1. One detail the original did not mention: new MyDelegate<int>(plus) works even though plus is overloaded three times, because the delegate's signature picks the right overload. Today you would normally use Func<T, T, T> instead of declaring your own generic delegate.

Callbacks as a design: progress

The original called a method that receives a callback a "callback method". In real code the most common callback is progress reporting. The simplest form takes an Action<int>:

static void Process(int items, Action<int> onProgress, CancellationToken ct)
{
    for (int i = 1; i <= items; i++)
    {
        ct.ThrowIfCancellationRequested();
        Thread.Sleep(1);                       // the real work
        onProgress(i * 100 / items);
    }
}

Process(5, p => Console.Write($"{p}% "), CancellationToken.None);
Console.WriteLine("| done (Action)");
20% 40% 60% 80% 100% | done (Action)

The callback runs synchronously, on the worker's thread, in order. That is fine for a console tool and wrong for a UI: the callback would touch UI controls from a background thread.

.NET's answer is IProgress<T> and its standard implementation Progress<T>. Progress<T> captures the SynchronizationContext when it is created and posts each report there. In a WPF, WinForms or MAUI app that is the UI thread, which is exactly right. In a console app or ASP.NET Core there is no context, so each report is queued to the thread pool. We passed the same Progress<T> to the same loop:

20% 40% 60% 80% | done (Progress<T>)
100%

The method returned and printed done before the last report ran. With the Thread.Sleep(1) removed, the effect was worse. Ten reports, five runs:

IProgress<int> ip = new Progress<int>(p => Console.Write($"{p} "));
for (int i = 1; i <= 10; i++) ip.Report(i);
Console.WriteLine("| loop finished");
| loop finished
6 5 8 2 4 10 9 7 3 1
| loop finished
7 6 4 8 1 5 3 9 2 10
| loop finished
2 8 4 7 3 1 6 9 10 5

Every run printed a different order, and every report arrived after the loop finished. Keep it as the parameter type anyway: accept IProgress<T>, so a UI caller can pass Progress<T> and a console or test caller can pass a tiny synchronous class of its own (class SyncProgress<T>(Action<T> a) : IProgress<T> { public void Report(T v) => a(v); }). With that class, five reports printed 1 2 3 4 5 | done, in order.

Reporting too often has a cost as well. We reported progress 1,000,000 times from one loop and timed until every callback had run. Method: .NET 10.0.14, Release build, Stopwatch, one warm-up pass then three timed runs, Apple Silicon Mac (M1 Pro).

Callback Run 1 Run 2 Run 3
Action<int>, 1M calls 2.91 ms 2.23 ms 3.82 ms
Progress<T>, 1M reports, until all ran 250.7 ms 246.9 ms 214.8 ms
Progress<T>, report only when % changes (100) 1.74 ms 1.12 ms 1.07 ms
Bar chart: a plain Action<int> callback took 3.0 ms for one million reports. Progress<T> took 237 ms until all one million callbacks had run on the thread pool, and allocated about 64 bytes per report. Progress<T> reporting only when the percentage changed, 100 reports, took 1.3 ms.
Reporting progress 1,000,000 times from one loop, time until every callback had run (average of 3 runs). .NET 10.0.14, Release build, Apple Silicon Mac (M1 Pro). Shorter is faster.

Each Progress<T> report allocated about 64 bytes (89 bytes in the first run, measured with GC.GetTotalAllocatedBytes) and cost roughly 80 times as much as a direct call. The fix is simple and has nothing to do with delegates: report when the visible value changes, not on every item.

Cancellation with CancellationToken

A callback can also be how the caller stops the work. The standard way is a CancellationToken argument checked inside the loop. Here the progress callback itself cancels at 40%:

using var cts = new CancellationTokenSource();
try
{
    Process(5, p => { Console.Write($"{p}% "); if (p >= 40) cts.Cancel(); }, cts.Token);
}
catch (OperationCanceledException e)
{
    Console.WriteLine($"| {e.GetType().Name}: {e.Message}");
}
20% 40% | OperationCanceledException: The operation was canceled.

Three conventions are worth following: take the token as the last parameter, named cancellationToken or ct, defaulting to default in public APIs; check it at the top of each unit of work with ThrowIfCancellationRequested(); and pass it on to every async API you call (Task.Delay(ms, ct), HttpClient.GetAsync(url, ct)). Do not invent a Func<bool> shouldStop callback: the token already composes with timeouts (cts.CancelAfter) and linked sources.

Delegate chains: what a throwing handler does

The 2021 chain example, with the typos fixed (and the output strings translated), printed what it promised:

first second third
 second

Two things the original did not show. First, -= that removes the last method leaves the variable null; we checked and got null after removing the last one, and a nullable-enabled project warns with CS8601 and CS8602 along the way. Raise chains with ?.Invoke.

Second, the important one in callback code: what happens when one handler throws. We built a "file saved" chain with a logger, a handler that fails, and a cache invalidator:

Action<string> onSaved = name => Console.WriteLine($"  [log]   saved {name}");
onSaved += name => throw new IOException("disk full");
onSaved += name => Console.WriteLine($"  [cache] invalidate {name}");

try { onSaved("a.txt"); }
catch (Exception e) { Console.WriteLine($"  caught {e.GetType().Name}: {e.Message}"); }
  [log]   saved a.txt
  caught IOException: disk full

The cache was never invalidated. A multicast call is a plain loop with no try; the first exception leaves the loop and goes to the caller, and the remaining handlers are skipped. Nothing is rolled back either: the log line was already written. In a real app this shows up as "sometimes the cache is stale", far away from the actual failure.

Two rows. Top row, plain onSaved(name): handler 1 log runs, handler 2 throws IOException, handler 3 cache is skipped and the exception goes to the caller. Bottom row, a loop over GetInvocationList with try/catch around each call: handler 1 runs, handler 2's exception is stored, handler 3 runs, then one AggregateException is thrown.
One multicast call versus a GetInvocationList loop when the second handler throws. The plain call never reaches the third handler; the loop runs all three and reports the failure afterwards.

If every handler must run, iterate GetInvocationList() yourself and collect the failures:

static void InvokeAll<T>(Action<T>? handlers, T arg)
{
    if (handlers is null) return;
    List<Exception>? errors = null;
    foreach (Action<T> h in handlers.GetInvocationList())
    {
        try { h(arg); }
        catch (Exception e) { (errors ??= []).Add(e); }
    }
    if (errors is not null) throw new AggregateException(errors);
}
  [log]   saved a.txt
  [cache] invalidate a.txt
  1 failure(s): disk full

All three ran, and the caller still learns that something failed. GetInvocationList() allocates a new array on every call, which was measured in the delegates and events article; for a "file saved" notification that does not matter.

Getting every return value with GetInvocationList

A chain of Funcs runs every method, but the call returns only the last value. With validators that quietly drops errors. Each validator below returns an error message or null:

Func<string, string?> validate = s => s.Length < 8 ? "too short" : null;
validate += s => s.Any(char.IsDigit) ? null : "no digit";
validate += s => s.Contains(' ') ? "contains space" : null;

Console.WriteLine(validate("ab c") ?? "null");
var errors = validate.GetInvocationList()
    .Cast<Func<string, string?>>()
    .Select(v => v("ab c"))
    .OfType<string>()
    .ToList();
Console.WriteLine(string.Join(", ", errors));
Console.WriteLine(validate("abcdefgh") ?? "null");
contains space
too short, no digit, contains space
null

The last line is the dangerous one: "abcdefgh" has no digit, the second validator returned "no digit", and the plain call still returned null because the last validator was satisfied. A caller that checks validate(s) is null accepts an invalid password. If you need every result, either loop over GetInvocationList() as above or, better, keep the validators in a List<Func<string, string?>>, which states the intent.

Choosing a callback shape

Situation Use
Method needs one function from the caller (operation, filter) Func<…> / Action<…> parameter
Report progress from long work IProgress<T> parameter
Progress in a console app or test, in order your own synchronous IProgress<T>
Let the caller stop the work CancellationToken as the last parameter
Several listeners, publisher should not know them event (basics, advanced)
Every listener must run even if one fails loop over GetInvocationList() with try, then AggregateException
You need every return value GetInvocationList() loop, or a List<Func<…>>
Many reports per second report only when the visible value changes

Tested with .NET SDK 10.0.403 / runtime 10.0.14, C# 14, macOS on Apple Silicon (M1 Pro).

FAQ

Should a long-running method take an Action<int> or an IProgress<int> for progress?

Take IProgress<int>; callers can pass new Progress<int>(...) or any small class of their own. Progress<T> captures the SynchronizationContext, which is what you want in a UI app. In a console app or on a server there is none, so each report is queued to the thread pool: in our test ten reports printed in a different random order on every run, and the method returned before the last one ran.

What happens to the other handlers when one method in a delegate chain throws?

The chain stops at the throwing handler; every handler after it is skipped and the exception goes to the caller. Handlers that already ran are not undone. To run them all, loop over GetInvocationList(), wrap each call in try/catch, and throw an AggregateException at the end.

How do I get the return value of every method in a multicast delegate?

Calling the delegate returns only the last method's value. Call each one through GetInvocationList(). With three validators chained, the plain call returned only "contains space", while the loop returned all three messages. In our test a plain call even returned null although the second validator had found an error.

  • #C#
  • #delegate
  • #callback
  • #IProgress
  • #CancellationToken
  • #.NET

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑