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, the Task transitions 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 standard await expressions 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.