Asynchronous Lazy Loading Patterns in C
Asynchronous lazy loading allows C# applications to defer the
initialization of resource-heavy components—such as database
connections, remote API clients, or large file caches—until they are
actually needed, all without blocking the calling thread. While the
standard .NET Lazy<T> type excels at synchronous
deferral, it cannot natively await asynchronous initialization routines.
This article demonstrates how to implement robust asynchronous lazy
loading patterns in C# using Lazy<Task<T>>,
custom awaitable wrappers, and resilient retry mechanisms to handle
initialization failures gracefully.
The Limitation of Standard
Lazy<T>
The standard System.Lazy<T> is designed for
synchronous factory delegates. When an initialization process involves
asynchronous I/O, developers often fall into the trap of using
.Result or .Wait() inside the delegate:
// Anti-pattern: Blocking threads can lead to deadlocks and thread pool starvation
private readonly Lazy<ExpensiveResource> _badResource = new Lazy<ExpensiveResource>(() =>
{
return InitializeAsync().GetAwaiter().GetResult();
});Blocking on asynchronous code can cause thread pool starvation and deadlocks, especially in synchronization-context-sensitive environments like legacy ASP.NET or UI applications.
Pattern 1: Using
Lazy<Task<T>>
The simplest built-in way to achieve non-blocking lazy initialization
is wrapping a Task<T> inside
Lazy<T>:
public class SimpleAsyncLazyService
{
private readonly Lazy<Task<ExpensiveResource>> _resource;
public SimpleAsyncLazyService()
{
_resource = new Lazy<Task<ExpensiveResource>>(() => InitializeResourceAsync());
}
public async Task<ExpensiveResource> GetResourceAsync()
{
return await _resource.Value;
}
private async Task<ExpensiveResource> InitializeResourceAsync()
{
await Task.Delay(500); // Simulate network or I/O work
return new ExpensiveResource();
}
}Advantages and Drawbacks
- Advantage: Native to the runtime; thread-safe
execution is handled automatically by
Lazy<T>. - Drawback: If
InitializeResourceAsync()throws an exception, theTasktransitions into a faulted state. By default,Lazy<T>caches that faulted task, meaning all subsequent calls will fail immediately without retrying the initialization.
Pattern 2: The Awaitable
AsyncLazy<T>
To improve ergonomics and enable direct awaiting of the lazy
instance, you can combine Lazy<Task<T>> with a
custom awaiter implementation.
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
public class AsyncLazy<T> : Lazy<Task<T>>
{
public AsyncLazy(Func<Task<T>> taskFactory)
: base(() => Task.Run(taskFactory))
{
}
public AsyncLazy(Func<Task<T>> taskFactory, LazyThreadSafetyMode mode)
: base(() => Task.Run(taskFactory), mode)
{
}
// Expose the TaskAwaiter directly to allow: await myAsyncLazy;
public TaskAwaiter<T> GetAwaiter()
{
return Value.GetAwaiter();
}
public ConfiguredTaskAwaitable<T> ConfigureAwait(bool continueOnCapturedContext)
{
return Value.ConfigureAwait(continueOnCapturedContext);
}
}Usage
public class DataAccessLayer
{
private readonly AsyncLazy<IDbConnection> _connection;
public DataAccessLayer()
{
_connection = new AsyncLazy<IDbConnection>(async () =>
{
var conn = new SqlConnection("Server=...;");
await conn.OpenAsync();
return conn;
});
}
public async Task ExecuteQueryAsync()
{
// Directly awaited without referencing .Value
IDbConnection connection = await _connection;
// Use connection...
}
}Pattern 3: Resilient Async Lazy Loading (Retrying on Failure)
When initializing external network dependencies, transient failures
(e.g., brief outages or timeouts) should not permanently poison the lazy
instance. The following implementation uses a SemaphoreSlim
to guarantee single-execution thread safety while evicting faulted tasks
so that subsequent requests can retry initialization.
using System;
using System.Threading;
using System.Threading.Tasks;
public sealed class ResilientAsyncLazy<T>
{
private readonly Func<Task<T>> _factory;
private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
private Task<T>? _instance;
public ResilientAsyncLazy(Func<Task<T>> factory)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
public async Task<T> GetValueAsync()
{
// Fast path: return existing completed task
if (_instance != null && _instance.IsCompletedSuccessfully)
{
return _instance.Result;
}
await _semaphore.WaitAsync().ConfigureAwait(false);
try
{
// Re-check after entering lock
if (_instance != null && _instance.IsCompletedSuccessfully)
{
return _instance.Result;
}
_instance = _factory();
return await _instance.ConfigureAwait(false);
}
catch
{
// Reset state on failure so the next attempt can retry
_instance = null;
throw;
}
finally
{
_semaphore.Release();
}
}
}Choosing the Right Approach
- Use
Lazy<Task<T>>when dealing with internal, highly stable dependencies where caching a failure is acceptable or preferred. - Use
AsyncLazy<T>when you want cleaner syntax using standardawaitexpressions without managing lock scopes. - Use a Semaphore-based approach
(
ResilientAsyncLazy<T>) when your factory connects to remote dependencies over network boundaries where transient faults are anticipated and retries are necessary.