Improving .NET performance with startup initialization
TL;DR
The first request to an ASP.NET Core application is often slow because certain services, APIs, or database operations are initialized on-demand. By defining a simple IApplicationInitializer
interface and running initialization tasks during application startup, you can preload caches, warm up APIs, and prepare dependencies - resulting in a faster and better first user experience.
Have you ever noticed that the first request to your .NET application is often slower than the rest?
This is usually because some actions or libraries can perform heavy, time-consuming operations when they are used for the first time. For example:
- Libraries or services that rely on
Lazy<>
initialization, which only runs on the first call - Fetching and caching data from external services
- Calling an external service that remains cold (inactive) until the first request
- Database initialization tasks, like establishing a singleton connection during the first query
- Updating a cache repository (e.g., refreshing Redis data from the database)
The first execution of these operations is often slow and can lead to unpleasant user experiences. In web applications, this is even more problematic when concurrent requests trigger the same slow operations simultaneously.
A more efficient approach is to execute these tasks during the application startup process. This way, when the first user request arrives, the time-consuming work is already done.
Goals
I wanted a solution that:
- Allows any layer to contribute startup tasks without coupling everything to
Program.cs
- Supports ordering (some initialization tasks depend on others)
- Works with .NET dependency injection
- Is asynchronous
- Stays simple - no heavy frameworks or complicated conventions
The interface
I created a simple interface for application startup tasks:
public interface IApplicationInitializer { int Order { get; } Task InitializeAsync(); }
We will resolve and run all initializers after building the host but before app.Run()
, so startup blocks until critical warmup is done.
The Order
property controls the execution order, which is helpful when one initializer depends on another.
Across different layers of the application, I implemented specific initializers. Here are a couple of examples:
Example 1 - Preloading tenant configurations
Multi-tenant apps often need to retrieve tenant settings (branding, limits, features, etc.) on almost every request. Instead of hitting the database every time, we can load all tenant settings once at startup and cache them.
internal class TenantConfigurationInitializer : IApplicationInitializer { private readonly AppDbContext _dbContext; private readonly ICacheManager _cacheManager; private readonly ILogger<TenantConfigurationInitializer> _logger; public TenantConfigurationInitializer( AppDbContext dbContext, ICacheManager cacheManager, ILogger<TenantConfigurationInitializer> logger) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _cacheManager = cacheManager ?? throw new ArgumentNullException(nameof(cacheManager)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } // Runs early so other initializers can rely on tenant config if needed public int Order => 5; public async Task InitializeAsync() { _logger.LogInformation("Preloading tenant configurations..."); var tenants = await _dbContext.Tenants.AsNoTracking().ToListAsync(); foreach (var tenant in tenants) { var config = Helpers.BuildTenantConfiguration(tenant); _cacheManager.Set($"tenant_config_{tenant.Id}", config, TimeSpan.FromHours(1)); } _logger.LogInformation("Cached configurations for {Count} tenants.", tenants.Count); } }
A simplified version of the TenantConfigProvider
looks like this. It first attempts to retrieve the configuration from the cache. If not found, it falls back to the database and caches the result.
public class TenantConfigProvider : ITenantConfigProvider { private readonly ICacheManager _cache; private readonly AppDbContext _dbContext; public TenantConfigProvider(AppDbContext dbContext, ICacheManager cache) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); } public async Task<TenantConfig> GetAsync(int tenantId) { var result = _cache.Get<TenantConfig>($"tenant_config_{tenantId}"); if (result == null) { var tenant = await _dbContext.Tenants.AsNoTracking().FirstAsync(p => p.Id == tenantId); result = Helpers.BuildTenantConfiguration(tenant); _cache.Set($"tenant_config_{tenantId}", result, TimeSpan.FromHours(1)); } return result; } }
By executing the TenantConfigurationInitializer
during startup, all tenant configurations are pre-loaded into the cache, avoiding slow lookups later.
Example 2 - Warming up an external service
internal class ExternalApiWarmupInitializer : IApplicationInitializer { private readonly HttpClient _client; private readonly ILogger<ExternalApiWarmupInitializer> _logger; public ExternalApiWarmupInitializer(IHttpClientFactory clientFactory, ILogger<ExternalApiWarmupInitializer> logger) { _client = clientFactory?.CreateClient() ?? throw new ArgumentNullException(nameof(clientFactory)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public int Order => 20; public async Task InitializeAsync() { _logger.LogInformation("Warming up external API..."); await _client.GetAsync("https://api.example.com/health"); _logger.LogInformation("External API warmup completed"); } }
Registering and executing initializers
In Program.cs
, register your initializers as transient services:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); // other application services builder.Services.AddTransient<IApplicationInitializer, TenantConfigurationInitializer>(); builder.Services.AddTransient<IApplicationInitializer, ExternalApiWarmupInitializer>(); builder.Services.AddTransient<IApplicationInitializer, CacheApplicationsInitializer>();
Just before running the app, resolve and execute the application initializers, in order:
var builder = WebApplication.CreateBuilder(args); // application services... var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>(); var initializers = scope.ServiceProvider .GetServices<IApplicationInitializer>() .OrderBy(p => p.Order) .ToList(); logger.LogInformation("Application initializers started. Found: {Count}", initializers.Count); foreach (var initializer in initializers) { try { await initializer.InitializeAsync(); } catch (Exception ex) { logger.LogError(ex, "Initializer {Name} failed.", initializer.GetType().Name); // should we fail? } } logger.LogInformation("Application initializers completed."); } app.Run();
Final thoughts
What makes this pattern useful:
- Separation of concerns – each initializer is responsible for a single task
- Extensibility – adding a new startup task is as easy as creating a new
IApplicationInitializer
- Deterministic startup – the Order property ensures a predictable sequence
You can use this approach to warm up caches, load configurations, fetch API data, or initialize external services - all without cluttering Program.cs
or affecting the initial user experience.