DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

C# Async/Await and Delegates/Events: The Concepts Behind Every Responsive Application

Every endpoint in the C# API powering my techstackblog uses async/await. Every LINQ query I write is secretly built on delegates. These two concepts are usually taught in separate chapters, but in real production code they constantly overlap - an event handler is often async, and a delegate parameter is often awaited.

This post covers both, with the analogies that made them click for me and the mistakes I see most often in production code.

Part 1 — Async and Await

The Analogy

Imagine ordering food at a restaurant.

A synchronous waiter takes your order, walks to the kitchen, and stands there waiting for your food to finish cooking - completely blocked from serving any other table the entire time.

An asynchronous waiter takes your order, hands it to the kitchen, and immediately goes to serve other tables. When the kitchen finishes, the waiter is notified and brings the food over. One waiter handles many tables efficiently instead of freezing on one slow task.

async/await in C# is that asynchronous waiter. The thread handling your request is not blocked waiting on a slow database call - it gets released to handle other work and resumes once the data is ready.

The Problem It Actually Solves

Without async, a thread that calls a database query sits completely idle for the duration of that call - often 100-300 milliseconds for typical queries. Under load, every concurrent request needs its own thread sitting idle, and you run out of threads fast.

With async, the same thread that started the database call gets returned to the pool immediately after calling await. It goes and handles a different request. When the database responds, the original method resumes often on a different thread entirely. The result is the same hardware handling dramatically more concurrent requests.

Basic Syntax

The async keyword marks a method as asynchronous. The await keyword pauses execution at that point without blocking the thread - it's pausing the method, not the thread. Task represents work that completes at some
point in the future.

A typical async method looks like this: mark the method with async, return Task or Task of some type, and use await wherever you call another async method inside it.

Task vs Task<T> vs async void

Task represents async work with no return value - just a signal of completion. Task<T> represents async work that eventually produces a value of type T. Both of these can be awaited, which is essential.

async void should only be used for actual event handlers - UI button click handlers in WPF or WinForms, for example, which require that exact signature. Outside of event handlers, avoid async void entirely. You cannot await it, which means you cannot know when it completes or catch exceptions it throws. Those exceptions can crash the entire process instead of being caught gracefully.

Running Multiple Async Operations

Sequential awaiting runs operations one after another - if you await three independent calls that each take 100ms, the total wait is 300ms even though none of them depend on each other.

Task.WhenAll runs them concurrently instead. Start all three without awaiting individually, then await Task.WhenAll on all of them together. The total wait becomes roughly the duration of the slowest single operation, not the sum of all three. For any independent async operations, this is a significant and easy performance win.

Common Async Mistakes

The most common mistake is forgetting await entirely - calling an async method without awaiting it returns the Task object itself rather than the data inside it, which is rarely what you want and is an easy typo to miss.

The most dangerous mistake is calling .Result or .Wait() on a Task to force synchronous behavior. This can cause deadlocks in certain contexts, particularly in older ASP.NET applications, and always defeats the entire
purpose of using async in the first place. The fix is simple: await all the way up the call stack rather than blocking partway through.

A subtler mistake is wrapping something in async unnecessarily when there's no actual asynchronous work happening inside - this adds overhead with no benefit.

Part 2 — Delegates and Events

The Analogy

A delegate is like a job posting that describes the exact shape of work needed - "must accept two numbers, must return one number" - without specifying who fills that role. Any method matching that shape can be
plugged in interchangeably.

An event is like a newsletter subscription. The newsletter publisher doesn't know or care who has subscribed. It simply announces "new issue ready" and every subscriber gets notified automatically. The publisher and its subscribers are completely decoupled from each other.

What a Delegate Actually Is

A delegate is a type-safe reference to a method. You define the delegate type once, describing the parameters and return type a matching method must have. Then any method with that exact signature can be
assigned to a variable of that delegate type and invoked through it.

In practice, you almost never declare custom delegate types in modern C#. Three built-in generic delegates cover the overwhelming majority of real-world use:
Func represents a delegate with a return value, Action represents one with no return value, and Predicate always returns a boolean and is typically used for filtering conditions.

Multicast Delegates

A single delegate variable can reference multiple methods at once using the += operator. When invoked, every attached method runs in the order they were added. This is genuinely useful for scenarios like logging the same message to multiple destinations - console, file, and a telemetry service - by combining three separate Action delegates into one.

What an Event Actually Is

An event is essentially a delegate with restricted access. The class that declares the event can raise it, but nothing outside that class can raise it directly - external code can only subscribe or unsubscribe handlers
using += and -=. This protects the publishing class's control over exactly when the notification fires, which is the core difference between a plain delegate field and a proper event.

The standard pattern uses EventHandler or EventHandler<T> as the delegate type, where T is a custom class deriving from EventArgs that carries whatever data subscribers need. Raising the event uses the null-conditional operator before Invoke, which gracefully does nothing if no one has subscribed rather than throwing a null reference exception.

Why Events Instead of Direct Method Calls

Without events, a publishing class needs to know about and directly call every interested party - adding a new subscriber means editing the publisher's code to add another direct call. This tightly couples the publisher to every single consumer of its notifications.

With events, the publisher simply raises the event and has zero knowledge of who is listening or how many subscribers exist. Adding a new subscriber is just one line of subscription code added externally - the publisher class itself never changes. This is loose coupling in its purest practical form.

Events Combined With Async

In real production systems, event handlers are frequently async themselves - an event fires, and the handler needs to do asynchronous work like sending an alert through an external API. This is one of the rare legitimate contexts for async void, since event handler delegate signatures typically can't be changed to return Task.

Where You're Already Using Delegates Without Realizing It

Every LINQ method that takes a lambda is actually accepting a delegate parameter. Where takes a Func<T, bool> - a delegate that takes one item and returns true or false. Select takes a Func<T, TResult> - a delegate that transforms one item into another shape. OrderBy takes a delegate that extracts the sort key. Every single LINQ chain you write is built entirely on the delegate pattern, even though it rarely feels that way while writing it.

ASP.NET Core middleware is built on the same foundation - the next parameter passed into each middleware delegate is itself a delegate pointing to the next piece of middleware in the pipeline.

Key Lessons From Production

  • Always await all the way up the call stack. Never call .Result or .Wait() on a Task anywhere in application code.

  • Use Task.WhenAll whenever you have multiple independent async operations it's an easy, safe performance improvement that's frequently overlooked.

  • Prefer the built-in Func, Action, and Predicate delegate types over declaring custom delegate types. Custom delegates are rarely necessary in modern C#.

  • Use the null-conditional invoke pattern when raising events - it prevents a null reference exception in the common case where no one has subscribed yet.

  • Reserve async void exclusively for actual event handler signatures. Everywhere else, use async Task so exceptions propagate correctly and the work can be awaited.

  • Recognizing that every LINQ lambda is secretly a delegate makes the entire LINQ API click faster - it stops feeling like special syntax and starts feeling like an extension of something you already understand.

Summary

Async/await keeps applications responsive by releasing threads during slow operations instead of blocking them, allowing far more concurrent work on the same hardware.
Delegates provide type-safe references to methods, letting behavior be passed around as data. Events build on delegates to create loosely coupled systems where publishers and subscribers never need direct knowledge
of each other.

Together, these two concepts are the backbone of responsive, maintainable C# applications - showing up everywhere from REST API controllers to UI event handlers to the LINQ queries written dozens of times a
day without a second thought.


Originally published at TechStack Blog:
https://www.techstackblog.com/post.html?slug=csharp-async-delegates-events-deep-dive

More from the same blog, organized by topic:

Azure: https://www.techstackblog.com/category.html?cat=azure
C# / .NET: https://www.techstackblog.com/category.html?cat=csharp
Web fundamentals: https://www.techstackblog.com/category.html?cat=web
Database: https://www.techstackblog.com/category.html?cat=database

Follow for weekly posts on C#, Azure integration, and cloud engineering.

Top comments (0)