Your users don't care that your Azure SQL query returns perfectly normalized data in 800ms—they just know your dashboard feels slow. Performance optimization in distributed cloud applications requires a different mindset than traditional on-premises apps, and the tools that helped you tune desktop applications won't cut it anymore.
The Cloud Performance Paradox
Moving to Azure often introduces new performance challenges. Network latency between services, cold start times for serverless functions, and the shared nature of cloud resources create bottlenecks that didn't exist in your local development environment. A database query that takes 2ms on localhost suddenly takes 50ms in production because your app server and database live in different regions.
The good news: Azure provides powerful tools for identifying and fixing these issues. The bad news: most developers don't use them effectively until after users complain.
Let's fix that.
Understanding Your Performance Baseline
Before optimizing anything, you need to know what's slow and why. Application Insights gives you this visibility automatically if you configure it correctly.
Add the Application Insights SDK to your ASP.NET Core application:
dotnet add package Microsoft.ApplicationInsights.AspNetCore
Configure it in your Program.cs with proper sampling to control costs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString = builder.Configuration["ApplicationInsights:ConnectionString"];
options.EnableAdaptiveSampling = true;
options.EnablePerformanceCounterCollectionModule = true;
});
// Configure sampling for high-traffic scenarios
builder.Services.Configure<Microsoft.ApplicationInsights.WindowsServer.TelemetryConfiguration>(
config =>
{
config.TelemetryInitializers.Add(new OperationCorrelationTelemetryInitializer());
});
var app = builder.Build();
Application Insights automatically tracks HTTP requests, dependencies, and exceptions. But the real power comes from custom telemetry that tracks your business operations:
public class OrderService
{
private readonly TelemetryClient _telemetry;
private readonly IOrderRepository _repository;
public OrderService(TelemetryClient telemetry, IOrderRepository repository)
{
_telemetry = telemetry;
_repository = repository;
}
public async Task<Order> ProcessOrder(OrderRequest request)
{
using var operation = _telemetry.StartOperation<DependencyTelemetry>("ProcessOrder");
operation.Telemetry.Properties["OrderValue"] = request.TotalAmount.ToString();
operation.Telemetry.Properties["ItemCount"] = request.Items.Count.ToString();
try
{
// Track specific operations within the process
var validationStart = DateTime.UtcNow;
await ValidateOrder(request);
_telemetry.TrackMetric("OrderValidationDuration",
(DateTime.UtcNow - validationStart).TotalMilliseconds);
var order = await _repository.CreateOrder(request);
operation.Telemetry.Success = true;
return order;
}
catch (Exception ex)
{
operation.Telemetry.Success = false;
_telemetry.TrackException(ex);
throw;
}
}
}
This level of instrumentation lets you see exactly which parts of your order processing pipeline are slow. When you look at Application Insights in the Azure Portal, you'll see your custom operations alongside built-in dependency tracking for database calls, HTTP requests, and Redis operations.
Caching: The Fastest Query is the One You Don't Make
Caching is often the highest-impact optimization you can make. Azure Cache for Redis provides a managed, scalable cache that works seamlessly with .NET applications.
Create an Azure Cache for Redis instance through the Azure Portal. For production workloads, use the Standard or Premium tiers—the Basic tier shares infrastructure and doesn't provide an SLA.
Install the StackExchange.Redis package:
dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis
Configure distributed caching in your application:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["Redis:ConnectionString"];
options.InstanceName = "MSCloudApp_";
});
Implement a caching layer for expensive operations:
public class ProductService
{
private readonly IDistributedCache _cache;
private readonly IProductRepository _repository;
private readonly ILogger<ProductService> _logger;
private readonly DistributedCacheEntryOptions _cacheOptions;
public ProductService(
IDistributedCache cache,
IProductRepository repository,
ILogger<ProductService> logger)
{
_cache = cache;
_repository = repository;
_logger = logger;
_cacheOptions = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2)
};
}
public async Task<Product> GetProduct(int productId)
{
var cacheKey = $"product_{productId}";
// Try to get from cache first
var cachedProduct = await _cache.GetStringAsync(cacheKey);
if (cachedProduct != null)
{
_logger.LogInformation("Cache hit for product {ProductId}", productId);
return JsonSerializer.Deserialize<Product>(cachedProduct);
}
// Cache miss - fetch from database
_logger.LogInformation("Cache miss for product {ProductId}", productId);
var product = await _repository.GetById(productId);
if (product != null)
{
// Store in cache for next time
var serialized = JsonSerializer.Serialize(product);
await _cache.SetStringAsync(cacheKey, serialized, _cacheOptions);
}
return product;
}
public async Task UpdateProduct(Product product)
{
await _repository.Update(product);
// Invalidate cache when data changes
var cacheKey = $"product_{product.Id}";
await _cache.RemoveAsync(cacheKey);
}
}
This pattern is called cache-aside. Your application checks the cache first, falls back to the database on a miss, and populates the cache for subsequent requests. The sliding expiration keeps frequently accessed items cached while allowing rarely used items to expire.
For read-heavy workloads with infrequent updates, this pattern can reduce database load by 90% or more. I've seen API response times drop from 200ms to 5ms after implementing Redis caching for product catalogs.
Smart Caching Strategies
Not all data should be cached the same way. User-specific data needs different treatment than shared reference data.
For frequently changing data that's expensive to compute, use a short cache duration with refresh-ahead logic:
public class DashboardService
{
private readonly IDistributedCache _cache;
private readonly TelemetryClient _telemetry;
public async Task<DashboardData> GetDashboard(string userId)
{
var cacheKey = $"dashboard_{userId}";
var cachedData = await _cache.GetStringAsync(cacheKey);
if (cachedData != null)
{
var dashboard = JsonSerializer.Deserialize<DashboardData>(cachedData);
// Refresh cache in background if it's getting stale
if (dashboard.CachedAt < DateTime.UtcNow.AddMinutes(-4))
{
_ = Task.Run(async () => await RefreshDashboardCache(userId));
}
return dashboard;
}
return await RefreshDashboardCache(userId);
}
private async Task<DashboardData> RefreshDashboardCache(string userId)
{
var dashboard = await BuildDashboard(userId);
dashboard.CachedAt = DateTime.UtcNow;
var serialized = JsonSerializer.Serialize(dashboard);
await _cache.SetStringAsync(
$"dashboard_{userId}",
serialized,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }
);
return dashboard;
}
}
This refresh-ahead pattern ensures users rarely experience cache misses because you proactively refresh data before it expires.
Memory Caching for Hot Data
For data that changes rarely and gets accessed constantly, use in-memory caching in addition to distributed caching. This eliminates even the Redis network roundtrip:
builder.Services.AddMemoryCache();
builder.Services.AddStackExchangeRedisCache(options => { /* config */ });
public class CategoryService
{
private readonly IMemoryCache _memoryCache;
private readonly IDistributedCache _distributedCache;
private readonly ICategoryRepository _repository;
public async Task<IEnumerable<Category>> GetCategories()
{
// L1 cache: in-memory
if (_memoryCache.TryGetValue("all_categories", out IEnumerable<Category> categories))
{
return categories;
}
// L2 cache: Redis
var cachedJson = await _distributedCache.GetStringAsync("all_categories");
if (cachedJson != null)
{
categories = JsonSerializer.Deserialize<IEnumerable<Category>>(cachedJson);
_memoryCache.Set("all_categories", categories, TimeSpan.FromMinutes(5));
return categories;
}
// Cache miss on both levels - load from database
categories = await _repository.GetAll();
var json = JsonSerializer.Serialize(categories);
await _distributedCache.SetStringAsync("all_categories", json,
new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) });
_memoryCache.Set("all_categories", categories, TimeSpan.FromMinutes(5));
return categories;
}
}
This two-level caching strategy works well for reference data like categories, countries, or configuration settings that don't change often but get accessed on every request.
Profiling with Visual Studio
Application Insights shows you what's slow in production. Visual Studio's profiler shows you why it's slow during development.
Open your solution in Visual Studio 2022 and access the Performance Profiler through Debug → Performance Profiler. Select these tools for .NET application profiling:
CPU Usage identifies methods consuming the most processor time. This catches inefficient algorithms, excessive allocations, or unnecessary loops.
Memory Usage tracks allocations and identifies memory leaks. Large Gen 2 collections indicate you're keeping objects alive too long.
.NET Object Allocation shows which types are being allocated most frequently. High allocation rates cause garbage collection pressure, which impacts performance.
Run your application under the profiler and execute your typical user workflows. After stopping the profiling session, Visual Studio presents a detailed analysis.
Look for hot paths—methods that consume disproportionate CPU time. A single LINQ query that processes a large collection repeatedly can dominate your performance profile:
// Before optimization - allocates and processes list multiple times
public IEnumerable<OrderSummary> GetOrderSummaries(List<Order> orders)
{
return orders
.Where(o => o.Status == OrderStatus.Completed)
.Select(o => new OrderSummary
{
Id = o.Id,
Total = o.Items.Sum(i => i.Price * i.Quantity), // Enumerates items
ItemCount = o.Items.Count(), // Enumerates items again
CustomerName = o.Customer.Name
});
}
// After optimization - enumerate once
public IEnumerable<OrderSummary> GetOrderSummaries(List<Order> orders)
{
return orders
.Where(o => o.Status == OrderStatus.Completed)
.Select(o =>
{
var total = 0m;
var count = 0;
foreach (var item in o.Items)
{
total += item.Price * item.Quantity;
count++;
}
return new OrderSummary
{
Id = o.Id,
Total = total,
ItemCount = count,
CustomerName = o.Customer.Name
};
});
}
The profiler would show the first version spending significant time in Sum() and Count() because each method enumerates the items collection separately. The optimized version processes items once.
Database Query Optimization
Application Insights tracks database queries automatically, showing you which queries are slowest. But it doesn't tell you why they're slow.
Enable detailed SQL logging in Entity Framework Core during development:
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
if (builder.Environment.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.EnableDetailedErrors();
options.LogTo(Console.WriteLine, LogLevel.Information);
}
});
Watch for N+1 queries—the most common Entity Framework performance problem:
// Bad - generates N+1 queries
var orders = await _context.Orders
.Where(o => o.CustomerId == customerId)
.ToListAsync();
foreach (var order in orders)
{
// Each iteration hits the database again
Console.WriteLine($"Items: {order.Items.Count}");
}
// Good - single query with eager loading
var orders = await _context.Orders
.Include(o => o.Items)
.Where(o => o.CustomerId == customerId)
.ToListAsync();
foreach (var order in orders)
{
Console.WriteLine($"Items: {order.Items.Count}");
}
Application Insights will show you that the first version makes multiple database calls. The logged SQL shows exactly what queries are being generated.
For complex queries, use AsNoTracking() when you don't need to update the entities:
var summary = await _context.Orders
.AsNoTracking()
.Where(o => o.Status == OrderStatus.Completed)
.Select(o => new
{
o.Id,
o.Total,
CustomerName = o.Customer.Name
})
.ToListAsync();
This eliminates change tracking overhead and only selects the columns you actually need.
Response Compression
For API responses or web pages with large JSON payloads, enable response compression:
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = System.IO.Compression.CompressionLevel.Fastest;
});
var app = builder.Build();
app.UseResponseCompression();
Brotli provides better compression than Gzip but requires more CPU. Use the Fastest compression level for dynamic content—the time saved in network transfer typically outweighs the CPU cost.
Monitor the impact through Application Insights. You should see reduced response sizes without significantly increased server processing time.
Monitoring the Right Metrics
Set up Application Insights alerts for metrics that matter:
- 95th percentile response time exceeding your SLA
- Dependency failure rate above 1%
- Exception count spikes
- Cache hit ratio dropping below 80%
In the Azure Portal, create alerts that notify your team before users complain. A slow dashboard is annoying; a dashboard that gets slower every day is a ticking time bomb.
Track your cache hit ratio with custom metrics:
public async Task<T> GetFromCacheOrSource<T>(string key, Func<Task<T>> source)
{
var cached = await _cache.GetStringAsync(key);
if (cached != null)
{
_telemetry.TrackMetric("CacheHitRate", 1);
return JsonSerializer.Deserialize<T>(cached);
}
_telemetry.TrackMetric("CacheHitRate", 0);
var value = await source();
await _cache.SetStringAsync(key, JsonSerializer.Serialize(value), _cacheOptions);
return value;
}
A cache hit rate below 70% suggests your cache duration is too short or your cache keys aren't granular enough.
Key Takeaways
Performance optimization is measurement-driven. Application Insights gives you production visibility, while Visual Studio's profiler identifies specific bottlenecks during development.
Caching is your highest-leverage optimization. Implement distributed caching with Azure Cache for Redis for data shared across instances, and use in-memory caching for hot reference data accessed on every request.
Watch for N+1 queries in Entity Framework. Use eager loading with Include() and AsNoTracking() for read-only scenarios. Log SQL queries during development to understand what EF Core generates.
Profile your application under realistic load before optimization. Premature optimization wastes time—measure first, optimize hot paths identified by data.
Set up alerts for degrading performance metrics. Users shouldn't be your monitoring system. Application Insights can notify you when response times exceed thresholds or cache hit rates drop.
These patterns work for most .NET applications on Azure. Start with caching your most expensive operations, instrument your code with Application Insights, and profile periodically to catch regressions. Your users won't thank you for making your app faster—they'll just expect it to stay that way.