Back to Blog
C#

C# async lambda: Syntax, Behavior, and Pitfalls

c# async lambda: Explains C# async lambda syntax, where async lambdas are allowed, and the runtime behavior that causes common failures.

C#async/awaitlambda expressionsTaskLINQasync void
Technical illustration of an async lambda expression in C# showing a task returning from a lambda with await inside.

The c# async lambda is a lambda expression that uses the async modifier and returns a Task or Task<T>. It lets you write await inside the lambda body, which is useful when you need to call asynchronous methods from delegates that expect a Func<Task> or similar type.

Func<Task> work = async () => { await Task.Delay(100); Console.WriteLine("Done"); };

The compiler transforms this into a state machine, just like an async method. The lambda itself is a delegate that, when invoked, returns a Task representing the ongoing operation.

The Basic Syntax of an async Lambda

An async lambda looks like a normal lambda with the async keyword added before the parameter list. The return type is inferred from the delegate type you assign it to.

Func<int, Task<int>> doubleAsync = async (int value) => { await Task.Delay(10); return value * 2; };

When the delegate type is Func<Task>, the lambda returns a Task. When it is Func<T, Task<R>>, the lambda returns Task<R>. The async keyword is required; without it, you cannot use await inside the body.

A common mistake is writing an async lambda that returns a value but assigning it to a Func<Task> delegate. The compiler rejects that because the return type does not match the delegate signature.

Where async Lambdas Are Allowed

Async lambdas are allowed anywhere a delegate type with a Task-based return type is expected. That includes Func<Task> and Func<T, Task<R>> parameters, event handlers that accept EventHandler or RoutedEventHandler, Task.Run calls, and LINQ methods that accept Func<T, Task<R>>.

The key constraint is that the target delegate type must have a return type the compiler can map to a Task. If the delegate returns void, the lambda must be async void, which has different exception behavior.

Task.Run(async () => { await Task.Delay(50); Console.WriteLine("Background work"); });

Here Task.Run accepts a Func<Task>, so the async lambda is a natural fit. The returned Task is awaited by the caller or observed for completion.

async void Lambdas in Event Handlers

Event handlers in .NET typically use the EventHandler delegate, which returns void. To use await inside an event handler, you write an async void lambda.

button.Click += async (sender, args) => { await LoadDataAsync(); UpdateUi(); };

The problem with async void is exception propagation. An exception thrown inside an async void method is not caught by the surrounding try/catch and is not returned on a Task. Instead, it is posted to the synchronization context, which usually results in an unhandled exception that can crash the process.

This is acceptable for UI event handlers where the framework installs a top-level exception handler, but it is dangerous in library code. If you control the delegate type, prefer a Func<Task>-based signature so exceptions can be observed.

async Lambdas in LINQ Queries

LINQ methods like Select, Where, and SelectMany expect synchronous delegates. If you pass an async lambda to Select, the lambda returns a Task<T>, and the result is an IEnumerable<Task<T>> rather than an IEnumerable<T>.

IEnumerable<Task<int>> tasks = ids.Select(async id => { return await FetchAsync(id); });

This is often a mistake. The query itself does not await anything; it just builds a sequence of tasks. To get the actual values, you must await all the tasks, for example with Task.WhenAll.

int[] results = await Task.WhenAll(ids.Select(async id => { return await FetchAsync(id); }));

If the lambda does not need to await anything, there is no reason to make it async. A synchronous lambda that returns the task directly is simpler and avoids the extra state machine.

Task<int>[] tasks = ids.Select(id => FetchAsync(id)).ToArray();

Error Handling in async Lambdas

Exceptions inside an async lambda are captured by the returned Task. The caller must observe that task, either by awaiting it or by attaching a continuation, or the exception becomes an unobserved task exception.

Func<Task> risky = async () => { throw new InvalidOperationException("boom"); }; Task task = risky(); // Exception is stored in the task, not thrown here.

If the task is never awaited and never observed, the exception is eventually raised as an unobserved task exception on the finalizer thread. In many hosting environments this terminates the process. The safe pattern is to always await the task where the lambda is invoked, or to handle the failure inside the lambda itself.

try { await risky(); } catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }

For fire-and-forget scenarios, attach a continuation that observes the exception rather than leaving the task unobserved.

Concurrency and Fire-and-Forget Behavior

When you invoke an async lambda without awaiting it, the lambda starts executing synchronously until the first await that does not complete synchronously. At that point control returns to the caller, and the rest of the lambda runs on the captured synchronization context or the thread pool.

Task fireAndForget = DoWorkAsync(); // The lambda runs until the first incomplete await.

This matters when you launch multiple async lambdas in a loop. Each invocation creates a new task, and the operations run concurrently. If you need them to run sequentially, you must await each one inside the loop.

foreach (var id in ids) { await ProcessAsync(id); }

If you want concurrency, collect the tasks and await them together with Task.WhenAll. Do not assume that invoking an async lambda in a loop serializes the work.

Captured State and Lifetime in async Lambdas

An async lambda captures variables from the enclosing scope, just like a synchronous lambda. The captured state lives as long as the task returned by the lambda. This can extend the lifetime of objects beyond the method that created the lambda.

public Task RunAsync() { var client = new HttpClient(); Func<Task> work = async () => { await client.GetAsync("https://example.com"); }; return work(); }

The client is captured by the lambda and remains alive until the task completes. If the task is long-running, the captured objects stay in memory for that duration. This is usually fine, but it is worth being explicit about disposal when the captured object holds unmanaged resources.

A subtle issue arises when a loop variable is captured. In older C# versions, the loop variable was shared across iterations, so all lambdas captured the same variable. Since C# 5, the loop variable in a foreach is per-iteration, but a for loop still shares the variable unless you copy it locally.

for (int i = 0; i < 10; i++) { int captured = i; Task.Run(async () => await WorkAsync(captured)); }

Copying the loop variable into a local before the lambda ensures each task receives the correct value.

c# async lambda: Practical Usage and Code Examples | RYUSLOG DEV