ASP.NET Core  

Why ASP.NET Core Feels Fast Locally but Slow in Production

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

  1. Measure request time per endpoint

  2. Measure database time per request

  3. Check thread pool starvation symptoms and blocking calls

  4. Reduce allocations, serialization overhead, and logging volume

  5. Add caching only for truly repeated reads

  6. 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.