WithEvents in VB.NET: Designing Decoupled Component Communication
WithEvents and Handles give VB.NET components a compile-time event bridge, but they create strong references that can leak memory. Learn the boundaries, failure modes, and when to switch to AddHandler or an Event Aggregator.
23 Dec 2025, 03:21 UTC

A view model must react when a sensor crosses a threshold, but the sensor class should not reference the view model, a logger, or any other consumer. Visual Basic .NET offers the WithEvents keyword as a quick bridge: declare a field with WithEvents, attach a method with the Handles clause, and the compiler wires the subscription. The useful takeaway is that this design gives you compile-time safety and automatic unsubscribe when the field is reset, but it also creates a strong reference from the publisher back to the subscriber. If the publisher is long-lived—an application service, a static event, or a singleton—the subscriber will not be garbage collected until the link is broken explicitly.
Requirements for a Minimal Event Bridge
Before choosing WithEvents, confirm the design meets four constraints:
- The publisher must expose an event, not a concrete subscriber type.
- The subscriber must know the publisher's type at compile time.
- Event payloads must cross the boundary without exposing mutable internal state.
- The subscriber's lifetime must be controllable so the handler can be detached.
If any constraint fails—especially the second—WithEvents is the wrong tool.
The Smallest Suitable Design: WithEvents Field and Handles Clause
The smallest viable pattern uses a private WithEvents field in the subscriber and a handler method marked with Handles. The publisher remains a plain class that raises an event; it has no reference to the subscriber.
Public Class TemperatureSensor
Public Event ThresholdExceeded(ByVal newValue As Double)
Public Sub UpdateReading(value As Double)
If value > 100.0R Then
RaiseEvent ThresholdExceeded(value)
End If
End Sub
End Class
Public Class AlertViewModel
Private WithEvents _sensor As TemperatureSensor
Public Sub Attach(sensor As TemperatureSensor)
_sensor = sensor
End Sub
Public Sub Detach()
_sensor = Nothing
End Sub
Private Sub OnThresholdExceeded(ByVal newValue As Double) _
Handles _sensor.ThresholdExceeded
' Update bound properties or raise PropertyChanged here
End Sub
End Class
In this example, AlertViewModel depends on TemperatureSensor, but the reverse is not true. The Handles clause creates a compile-time link between the _sensor field and the handler method. When Attach assigns the sensor, the compiler-generated code calls the equivalent of AddHandler. When Detach sets the field to Nothing, the compiler generates the equivalent of RemoveHandler.
Trust and Data Boundaries in Event Payloads
Events cross a trust boundary. If the publisher passes a mutable business entity, a subscriber can corrupt state that other subscribers rely on. The safer boundary is a dedicated EventArgs subclass that carries only the data needed, ideally as read-only properties.
Public Class ThresholdExceededEventArgs
Inherits EventArgs
Public ReadOnly Property NewValue As Double
Public Sub New(newValue As Double)
Me.NewValue = newValue
End Sub
End Class
By passing ThresholdExceededEventArgs instead of a raw sensor object, the publisher prevents subscribers from calling methods that change sensor configuration. This keeps the event contract narrow and testable.
Operational Checks: Wiring, Lifetime, and Compiled Behavior
Verifying the wiring
Place a breakpoint inside OnThresholdExceeded and call UpdateReading with a value above the threshold. If the breakpoint does not hit, confirm that _sensor is not Nothing and that the Handles clause references the correct field name. A common mistake is referencing a local variable instead of the WithEvents field.
Verifying lifetime
Because the event source holds a delegate that references the subscriber instance, a long-lived publisher keeps the subscriber alive. To check this, run the application under a memory profiler (for example, Visual Studio Diagnostic Tools, JetBrains dotMemory, or SciTech .NET Memory Profiler). Force a garbage collection, then inspect whether the subscriber instance is still rooted. If it is, the publisher has not released the handler.
What the compiler generates
Both WithEvents and explicit AddHandler ultimately manipulate the event's delegate invocation list. The difference is when and where. With WithEvents, the compiler emits a hidden property around the field; the setter contains the AddHandler/RemoveHandler calls. With explicit AddHandler, you control the timing and the object reference at runtime. You can inspect the generated IL with tools such as ILSpy or ildasm to confirm the property wrapper, though the delegate registration mechanism is the same.
| Aspect | WithEvents + Handles | AddHandler / RemoveHandler |
|---|---|---|
| Wiring time | Compile time, tied to field assignment | Explicit runtime calls |
| Flexibility | Fixed events known at compile time | Any compatible delegate at runtime |
| Unsubscription | Automatic when field set to Nothing | Manual and explicit |
| Scope | Handler must reside in same class as field | Can wire any object from any scope |
Failure Modes and Symptoms
- Zombie subscribers: A singleton or static publisher holds the handler, so the subscriber is never collected. Symptom: steady memory growth in a profiler even after views are closed. Mitigation: call
Detachwhen the subscriber is disposed, or use a weak-event pattern. - Thread affinity violations: If the publisher raises the event on a background thread, a handler that updates UI controls directly will throw in Windows Forms or WPF. Check
InvokeRequiredand marshal the update. - Silent disconnect: Assigning
Nothingto theWithEventsfield—even accidentally inside the handler—removes the subscription. The event fires, but the method is no longer called. - Hidden coupling: Although the publisher does not reference the subscriber, the subscriber's code contains a hard dependency on the publisher's concrete type. This makes unit testing harder unless the publisher is abstracted behind an interface.
Conditions That Would Change the Design
Abandon WithEvents when the architecture outgrows compile-time wiring:
- Runtime discovery: Plugins or dynamically loaded components cannot be referenced by a
WithEventsfield. UseAddHandlerwith a common interface or an Inversion of Control container. - Many-to-many routing: When multiple publishers and subscribers need to communicate without direct references, introduce an Event Aggregator or mediator. The aggregator owns the delegate lists, and components publish and subscribe through it.
- Cross-process boundaries: Events are in-process only. For cross-process or distributed scenarios, use a message queue, named pipes, or gRPC callbacks.
- High-frequency streams: If events fire thousands of times per second, consider
IObservable(Of T)or a reactive pipeline instead of individual event handlers.
Limitations and a Practical Smoke Test
WithEvents works only with events declared on classes; it cannot wrap arbitrary delegates or lambda expressions. It also requires the subscriber to be a reference type with a compiler-generated property, so it is unsuitable for value-type scenarios.
To smoke-test the pattern, create a small console or WinForms project. Instantiate the publisher, call Attach, raise the event, and confirm the handler executes. Then call Detach, raise the event again, and confirm the handler does not execute. Finally, run the application under a memory profiler, force a collection, and verify that the subscriber is collected after Detach. This sequence validates wiring, unsubscribe, and lifetime in one pass.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.