C# event subscriptions create relationships between a publisher and the callbacks that react to it. Adding a handler is easy to see in code. Ending the relationship can be less obvious, especially when initialization runs twice, a lambda wraps another callback, or a publisher has already captured the handlers for a notification.
The useful question is not merely whether -= appears somewhere. It is which registration that removal matches, how many registrations exist, and what lifetime the consumer intended to own. These mechanics explain notification behavior without requiring another weak-event helper or a demonstration based on forced garbage collection.
Documentation was checked online on October 11, 2026, using the C# specification and .NET 10 reference. The three complete examples use only standard-library console code. They are documented, not compiled or executed, and do not claim measured collection timing or concurrent thread safety.
An event exposes subscription, not its backing delegate
An event is a delegate-typed member, not a delegate type. Outside its declaring type, an accessible event supports += and -=. External consumers cannot directly obtain the underlying list or invoke the event member. The event specification establishes that boundary.
For a field-like event, the compiler provides storage and accessors that combine or remove delegates. If no handlers have been added, the storage contains null. An explicitly implemented event instead executes its custom add/remove statements, and its storage or matching policy may differ.
This means ordinary removal rules should be stated with their scope. The explanations here concern compiler-backed field-like events and delegate combination/removal unless a section says otherwise. A custom registration system can reject duplicates, wrap handlers, or remove all matches, but those behaviors require actual code and documentation.
The existing discussion of flexible reactions explains why optional subscriptions are useful. This article concentrates on the relationship's mechanics rather than repeating that architecture introduction. Flexibility depends on knowing which reaction is registered and when it stops being needed.
| Registration style | Identity to retain or reconstruct | Important limitation |
|---|---|---|
| Named instance method | Same target and method | Another instance is another target |
| Anonymous handler | Stored delegate value | Rewritten lambda source is not a removal plan |
| Wrapper around a callback | Wrapper that was added | Original callback may not be registered |
| Duplicate field-like registration | Equal invocation-list occurrence | One removal does not mean remove all |
| Captured dispatch snapshot | Existing delegate instance | Later removal does not rewrite it |
The table separates registration identity from application identity. A component may logically be "the current view," but delegate equality is not based on that label. If the view is recreated, a method on the new instance is not the same target as the method registered from the previous instance.
Named handlers make matching visible
For ordinary instance-method delegates, matching involves the target and method. Storing the delegate is still useful when the lifecycle spans several methods, but a named method on the same target can be supplied again for removal. What matters is not whether the source line looks identical, but whether it identifies a matching callable registration.
#nullable enable
using System;
var source = new ChangeSource();
var observer = new ChangeObserver();
EventHandler handler = observer.OnChanged;
source.Changed += handler;
source.Changed += handler;
source.Raise();
source.Changed -= handler;
source.Raise();
source.Changed -= handler;
source.Raise();
Console.WriteLine($"Notifications observed: {observer.Count}");
public sealed class ChangeSource
{
public event EventHandler? Changed;
public void Raise()
{
var handlers = Changed;
handlers?.Invoke(this, EventArgs.Empty);
}
}
public sealed class ChangeObserver
{
public int Count { get; private set; }
public void OnChanged(object? sender, EventArgs args)
{
Count++;
}
}
Adding the same single-cast handler twice creates two invocation-list entries. With ordinary delegate removal, one -= removes the last matching occurrence, not every equal registration. The first raise therefore has two entries, the next one, and the last none. The expected final count is three, based on the documented rules rather than an executed result.
The .NET 10 Delegate.Remove reference describes removal in terms of the last matching invocation-list occurrence. For a multicast right operand, that occurrence can be a matching contiguous sequence. The simple single-handler example is a special case, not the entire removal algorithm.
This matters when initialization or activation can happen repeatedly. A cleanup operation paired with one successful addition balances that addition, not an unknown number of earlier additions. Avoid labeling a method "unsubscribe everything" if it only performs one ordinary removal.
A custom event can intentionally define another duplicate policy. Rejecting repeated registration can make some lifecycle mistakes explicit, while allowing duplicates preserves ordinary event semantics. Neither choice is implicit in a custom accessor; the reader needs to inspect the actual registration implementation.
Anonymous callbacks and wrappers need retained identity
The subscription guide recommends storing an anonymous function in a delegate variable when later unsubscription is required. That advice makes the relationship explicit without depending on how the compiler represents two separately written anonymous expressions.
A wrapper adds another layer. If the stored event handler calls an original callback, the event contains the wrapper, not necessarily the original callback. Removing the original callback from the event does not generally identify the wrapper that was registered around it. Retain the actual delegate added to the event.
#nullable enable
using System;
var source = new MessageSource();
Action<string> receive = Console.WriteLine;
EventHandler<MessageArgs> wrapper = (_, args) => receive(args.Text);
source.Message += wrapper;
try
{
source.Publish("The registration is active.");
}
finally
{
source.Message -= wrapper;
}
source.Publish("The registration has ended.");
public sealed class MessageArgs : EventArgs
{
public MessageArgs(string text)
{
Text = text;
}
public string Text { get; }
}
public sealed class MessageSource
{
public event EventHandler<MessageArgs>? Message;
public void Publish(string text)
{
var handlers = Message;
handlers?.Invoke(this, new MessageArgs(text));
}
}
This wrapper performs a synchronous adaptation from event arguments to a string callback. It is not an asynchronous safety helper. The retained wrapper is used for both operations, and the finally block defines cleanup for this lexical scope if publishing exits normally or throws.
Creating another lambda that calls receive would create another expression, not a documented identity mapping back to this registration. The same issue appears when a helper returns an event handler around an async operation. A returned handler is a registration value that should be retained if later removal is needed.
The existing subscriber-side async adaptation has that identity requirement as well as async-void limitations. This guide reuses that reader job rather than introducing another adapter. Wrapper ownership is important independently of whether the wrapper handles asynchronous work.
The finally block is deliberately small. In a custom-accessor event, removal could execute arbitrary code and throw; that would need a cleanup failure policy. The sample uses a field-like event and does not claim its structure is a universal lifetime manager for every registration API.
Subscription lifetime and object lifetime are related
An ordinary event-backed delegate can keep an instance handler's target reachable through the publisher. If the publisher remains strongly reachable after the consumer's useful lifetime ends, the registration can retain that consumer. A lifetime mismatch is the issue, not the existence of an event by itself.
The event retention explanation explores that path. The qualification matters: an entirely unreachable publisher/subscriber graph can be collectible even if objects refer to one another. A cycle alone does not prove a permanent root, and an unsubscription example does not prove a particular collection time.
A lambda can capture the consumer, another helper, or local state. Its actual delegate target need not be identical to the application's logical listener. Reason about what the registered callable can access and what references keep that callable available, rather than assuming one universal closure shape.
Choose a useful lifetime boundary in application terms. A view might subscribe while active, a session while open, or a temporary operation only within a lexical scope. Constructor-time registration can be appropriate when that is the intended lifetime, but it should not obscure where the relationship ends.
Weak-event mechanisms change retention and can be useful with mismatched lifetimes. They also introduce identity and delivery questions of their own. The existing weak-event explanation belongs to that more advanced topic; weak storage is not an automatic substitute for declaring useful subscription lifetime.
This article intentionally does not use finalizers as a cleanup strategy or force garbage collection to claim leak freedom. Those techniques would answer a different question and can confuse eligibility with observed timing. The examples instead make registration additions and removals explicit.
Removing a handler does not rewrite a captured snapshot
Delegate instances retain the same invocation list once created. Combination and removal produce other delegate instances. The delegate specification states this directly. A publisher taking a local delegate copy establishes membership for that copy, not a live view of later event storage.
That rule can be demonstrated without threads. In the next example, the first callback removes the second callback while a raise is already walking the captured delegate. The second callback still belongs to that captured invocation list, even though the publisher's stored event has been changed for a subsequent raise.
#nullable enable
using System;
using System.Collections.Generic;
var source = new SnapshotSource();
var trace = new List<string>();
EventHandler second = (_, _) => trace.Add("second");
EventHandler first = (_, _) =>
{
trace.Add("first");
source.Changed -= second;
};
source.Changed += first;
source.Changed += second;
source.Raise();
source.Raise();
source.Changed -= first;
Console.WriteLine(string.Join(", ", trace));
public sealed class SnapshotSource
{
public event EventHandler? Changed;
public void Raise()
{
var handlers = Changed;
handlers?.Invoke(this, EventArgs.Empty);
}
}
The documentation-backed expected sequence is first, second, first. The first removal changes stored membership, but does not mutate the delegate already captured by the first raise. Removing the already-absent handler during the second raise leaves the ordinary stored invocation list unchanged.
This is a same-thread membership example. It does not prove a synchronization strategy for overlapping raises or concurrent access to subscriber state. The distinction is useful precisely because it avoids attributing a threading guarantee to a rule about delegate-instance immutability.
An API that promises no callbacks after a shutdown method returns needs more than ordinary -= if dispatch can already be in flight. It must define what counts as in flight, how work is coordinated, and whether callbacks can reenter shutdown. That additional contract is intentionally outside this simple subscription guide.
Registration removal is not task cancellation
An event handler may initiate work that lasts longer than the direct call. Removing its registration affects later membership; it does not retract a request that the handler already accepted. A conventional async void handler also gives the publisher no returned task through which to await that completion.
The async-event risk discussion explains that boundary. A Task-returning callback makes its own completion awaitable, but merely combining several Task-returning delegates does not observe every task. Earlier operations are not absorbed into the final returned task.
Cancellation belongs to the operation that accepts a cancellation token or another stop mechanism. Microsoft's cooperative cancellation documentation states that cancellation is not forced on the listener. A request can be ignored by code that does not observe it, so it cannot be presented as a universal execution deadline.
The lifetime lesson is to separate three statements: the consumer is no longer registered, previously dispatched work has completed, and the consumer's objects are eligible for collection. Each statement needs its own evidence. One successful removal proves none of the other statements automatically.
Review the relationship at its activation boundary
A practical subscription review starts where the consumer becomes active. Identify which publisher instance is used, which delegate is added, and whether activation can happen again before deactivation. If repeated activation is possible, a single removal is not evidence that every accumulated registration has ended. That conclusion follows from the duplicate-removal rule, not from elapsed time.
Then identify the deactivation boundary. A temporary callback can use a finally block as the wrapper example does. A longer-lived component may pair activation and deactivation methods or use an existing owner-provided registration token. A token can make ownership convenient, but its disposal semantics still need to specify whether it removes one registration, all matches, or something else.
There is no need to introduce a new token abstraction merely to demonstrate lifetime. The examples already make additions and removals visible. Adding a generic helper would introduce more policies to explain: repeated disposal, retained references, custom-accessor failures, and disposal during dispatch. Those questions are worthwhile only when the application actually needs that extra abstraction.
Finally, distinguish an absent registration from a consumer that should ignore an already-dispatched notification. That second behavior may require an application state check, and the state check has its own coordination requirements. It should not be described as a magical extension of delegate removal. Keeping each responsibility explicit makes the lifecycle review more useful than searching for any occurrence of the minus-equals operator.
Frequently asked questions
Does adding the same handler twice create two registrations?
For an ordinary field-like event backed by delegate combination, yes. The handler appears twice in the invocation list. Custom add accessors can choose another policy, but it must be documented rather than assumed from the += syntax.
Does one -= remove every duplicate handler?
No. Ordinary delegate removal removes the last matching invocation-list occurrence. For a single-cast handler added twice, one registration remains after one removal. A custom remove-all policy is possible, but differs from the ordinary single-occurrence behavior.
Must named methods use the exact same delegate object for removal?
Matching ordinary instance-method delegates involves the same target and method, so recreating a suitable delegate from that same named instance method can match. Retaining the value is still convenient. Another object instance is not the original target.
Can an anonymous event handler be unsubscribed?
Yes, when the registration can be identified. Store the anonymous handler in a delegate variable and use that value for removal. Rewriting a similar lambda later is not a reliable replacement for preserving registration identity.
Does removal prevent a callback in an already-captured raise?
Not by the ordinary delegate-removal rule. A captured delegate has its existing invocation list. The snapshot example shows a later callback still being called during that raise even after it is removed from the publisher's stored event.
Does unsubscribing prove that an object was garbage-collected?
No. It changes a registration relationship, and other references may still exist. Collection eligibility and actual collection timing are separate matters. These examples make no measured garbage-collection claim and do not use forced collection as proof.
Understand what C# event subscriptions actually end
A clear subscription lifecycle identifies the handler that was added, balances duplicate registrations, and defines the point at which that relationship is no longer useful. Retained wrapper identity and explicit cleanup boundaries make those mechanics visible.
Removal changes registration membership. It is not cancellation, a drain barrier, or proof of garbage collection. Keeping those conclusions separate makes the lifecycle easier to explain and prevents an ordinary -= from carrying promises it cannot fulfill.




