Why ASP.NET Core Feels Fast Locally but Slow in Production
Locally, your app typically runs with minimal traffic, minimal latency, and warm caches. In production, real users, real data volumes, real networks, and stricter settings expose bottlenecks.
The fix is not guessing. It is measuring, then fixing the highest impact bottlenecks first.
Environment Differences That Matter
Problem
Local is often Development with lighter behavior. Production is usually tighter and includes additional middleware, stricter security, and different configuration.
What to do
Verify environment and configuration at runtime and log key settings once at startup.
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
var app = builder.Build();
app.Logger.LogInformation("Environment: {Env}", app.Environment.EnvironmentName);
app.Logger.LogInformation("Urls: {Urls}", string.Join(", ", app.Urls));
app.Run();
Tip
Make sure production config is actually being loaded.
builder.Configuration
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddEnvironmentVariables();
Hosting and Infrastructure Bottlenecks
Problem
Production instances often have CPU throttling, memory caps, slow disk, and noisy neighbors. Containers frequently hit limits and then everything slows or restarts.
What to do
Expose basic health and performance signals so you can correlate slowness with resource pressure.
using System.Diagnostics;
var app = builder.Build();
app.MapGet("/diag", () =>
{
var proc = Process.GetCurrentProcess();
return Results.Ok(new
{
CpuTimeMs = proc.TotalProcessorTime.TotalMilliseconds,
WorkingSetMb = proc.WorkingSet64 / 1024 / 1024,
Gc0 = GC.CollectionCount(0),
Gc1 = GC.CollectionCount(1),
Gc2 = GC.CollectionCount(2)
});
});
app.Run();
If CPU time and GC counts jump with latency, your bottleneck is likely compute or allocations, not the network.
Database Performance Is Usually the Root Cause
Problem
Local databases are small and fast. Production databases have real volume, concurrency, and network latency. EF Core defaults can quietly become expensive.
Fix 1 Use NoTracking for read queries
var users = await db.Users
.AsNoTracking()
.Where(x => x.IsActive)
.Select(x => new { x.Id, x.Name })
.ToListAsync();
Fix 2 Avoid N plus 1 queries
Bad pattern:
var orders = await db.Orders.ToListAsync();
foreach (var o in orders)
{
var items = await db.OrderItems.Where(i => i.OrderId == o.Id).ToListAsync();
}
Better pattern with Include:
var orders = await db.Orders
.AsNoTracking()
.Include(o => o.Items)
.ToListAsync();
Or better, projection when you do not need full entity graphs:
var orders = await db.Orders
.AsNoTracking()
.Select(o => new
{
o.Id,
o.CreatedOn,
Items = o.Items.Select(i => new { i.Id, i.Sku, i.Qty })
})
.ToListAsync();
Fix 3 Use compiled queries for hot endpoints
using Microsoft.EntityFrameworkCore;
static readonly Func<AppDbContext, int, IAsyncEnumerable<User>> GetUserById =
EF.CompileAsyncQuery((AppDbContext db, int id) =>
db.Users.AsNoTracking().Where(u => u.Id == id));
var user = await GetUserById(db, id).SingleOrDefaultAsync();
Fix 4 Time your SQL in code
using System.Diagnostics;
var sw = Stopwatch.StartNew();
var result = await db.Users.AsNoTracking().Where(x => x.IsActive).ToListAsync();
sw.Stop();
logger.LogInformation("DB query took {Ms} ms and returned {Count} rows", sw.ElapsedMilliseconds, result.Count);
Async Blocking and Thread Pool Starvation
Problem
Works fine locally. Under load, blocking calls choke the thread pool and request latency skyrockets.
Avoid this:
var data = httpClient.GetStringAsync(url).Result;
Use this:
var data = await httpClient.GetStringAsync(url);
If you must use parallelism, do it safely
Do not fire hundreds of tasks with no limit. Use throttling:
var semaphore = new SemaphoreSlim(10);
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync();
try
{
return await httpClient.GetStringAsync(url);
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
Logging and Telemetry Overhead
Problem
Excessive logging in hot paths can destroy throughput. Logging large objects or serializing payloads is especially expensive.
Fix 1 Use structured logging and avoid string building
Bad:
logger.LogInformation("User data: " + JsonSerializer.Serialize(user));
Better:
logger.LogInformation("User fetched. Id {Id}", user.Id);
Fix 2 Lower log level for noisy categories in production
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
}
}
Fix 3 Add request timing middleware, cheap and useful
using System.Diagnostics;
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
await next();
sw.Stop();
if (sw.ElapsedMilliseconds > 500)
{
app.Logger.LogWarning("Slow request {Method} {Path} took {Ms} ms",
ctx.Request.Method, ctx.Request.Path, sw.ElapsedMilliseconds);
}
});
Cold Starts and Startup Time
Problem
Local dev hides cold starts. Production gets them from restarts, scale out, container redeploys.
Fix 1 Do not block startup with heavy work
Bad:
var config = LoadHugeConfigFromNetwork().Result;
Better: move to a hosted service and do it async.
public sealed class WarmupService : IHostedService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WarmupService> _logger;
public WarmupService(IHttpClientFactory httpClientFactory, ILogger<WarmupService> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient("warmup");
var sw = Stopwatch.StartNew();
try
{
using var resp = await client.GetAsync("https://example.com/health", cancellationToken);
_logger.LogInformation("Warmup status {Status} in {Ms} ms", (int)resp.StatusCode, sw.ElapsedMilliseconds);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Warmup failed");
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
Register it:
builder.Services.AddHttpClient("warmup");
builder.Services.AddHostedService<WarmupService>();
Caching Works Locally but Fails in Production
Problem
In memory caching is per instance. If you scale out, each instance has its own cache. That increases cache misses and hits your database harder.
Option 1 In memory cache for single instance or dev
builder.Services.AddMemoryCache();
app.MapGet("/products/{id:int}", async (int id, IMemoryCache cache, AppDbContext db) =>
{
var key = $"product:{id}";
if (cache.TryGetValue(key, out ProductDto cached))
return Results.Ok(cached);
var product = await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.SingleOrDefaultAsync();
if (product is null) return Results.NotFound();
cache.Set(key, product, TimeSpan.FromMinutes(5));
return Results.Ok(product);
});
Option 2 Distributed cache for multi instance production
Example with IDistributedCache:
builder.Services.AddStackExchangeRedisCache(opt =>
{
opt.Configuration = builder.Configuration.GetConnectionString("Redis");
});
app.MapGet("/products/{id:int}", async (int id, IDistributedCache cache, AppDbContext db) =>
{
var key = $"product:{id}";
var json = await cache.GetStringAsync(key);
if (!string.IsNullOrWhiteSpace(json))
return Results.Ok(JsonSerializer.Deserialize<ProductDto>(json));
var product = await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.SingleOrDefaultAsync();
if (product is null) return Results.NotFound();
await cache.SetStringAsync(
key,
JsonSerializer.Serialize(product),
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) });
return Results.Ok(product);
});
Network Latency and External Dependencies
Problem
Local often uses mocks. Production hits real services. Poor HttpClient usage can also cause socket exhaustion.
Fix 1 Always use IHttpClientFactory
builder.Services.AddHttpClient("Payments", client =>
{
client.Timeout = TimeSpan.FromSeconds(5);
});
Use it:
app.MapGet("/pay/status", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("Payments");
var resp = await client.GetAsync("https://payments.example.com/status");
return Results.Ok(new { Status = (int)resp.StatusCode });
});
Fix 2 Add resilience policies if you are calling flaky services
If you use Polly, keep retries small and timeouts strict, otherwise you amplify latency. A safe pattern is short timeout plus a couple retries, and a circuit breaker for repeated failure.
Even without Polly, do not let requests hang.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
var resp = await client.GetAsync(url, cts.Token);
How to Fix This Systematically
Measure request time per endpoint
Measure database time per request
Check thread pool starvation symptoms and blocking calls
Reduce allocations, serialization overhead, and logging volume
Add caching only for truly repeated reads
Scale infrastructure after code and database are in shape
This approach consistently beats random tuning.
Top 5 GEO Focused FAQs
Why does ASP.NET Core perform differently in production compared to local development
Production adds real traffic, real data volume, network latency, stricter middleware, and resource limits that do not exist locally.
What is the most common reason ASP.NET Core apps slow down in production
Database performance problems, especially inefficient EF Core queries and missing indexes, are the most frequent root cause.
Can async and await improve ASP.NET Core performance in production
Yes, when used correctly. Blocking calls like Result and Wait can cause thread pool starvation and make the app far slower.
How do I identify performance bottlenecks in ASP.NET Core production apps
Use request timing, database query timing, GC and CPU metrics, and distributed tracing to pinpoint which dependency or code path is slow.
Should I scale servers or optimize code first for ASP.NET Core performance
Optimize code and database first. Scaling inefficient code increases cost and often does not fix latency.