DEV Community

Cover image for Debugging Production and Performance Issues in .NET — A Practical Guide
Libin Tom Baby
Libin Tom Baby

Posted on

Debugging Production and Performance Issues in .NET — A Practical Guide

Application Insights, structured logging, SQL query plans, ILogger, BenchmarkDotNet, pagination vs full-table fetch, memory dumps

Production bugs and performance problems are different from development bugs.

You cannot attach a debugger. You have limited visibility. The system is under real load. And in many cases, the issue is not an exception — it's slow code, a memory leak, or a database query that works fine with 100 rows but falls apart with 4 million.


Part 1: Debugging Production Issues

Structured Logging — Your First Line of Defence

Production debugging starts and ends with good logs.

// ❌ Bad — unstructured, unsearchable
_logger.LogInformation("User " + userId + " placed order " + orderId);

// ✅ Good — structured, queryable
_logger.LogInformation(
    "User {UserId} placed order {OrderId} for {Product}",
    userId, orderId, product);
Enter fullscreen mode Exit fullscreen mode

Structured logging lets you query logs by field values in Application Insights, Seq, Datadog, or any log aggregator.

Correlation IDs

Every request should carry a trace ID from entry to exit — across services.

app.Use(async (context, next) =>
{
    var correlationId = context.Request.Headers["X-Correlation-Id"]
        .FirstOrDefault() ?? Guid.NewGuid().ToString();

    context.Response.Headers["X-Correlation-Id"] = correlationId;

    using (_logger.BeginScope(new { CorrelationId = correlationId }))
    {
        await next();
    }
});
Enter fullscreen mode Exit fullscreen mode

When a user reports a bug with the correlation ID, you can pull every log entry for that request in seconds.

Application Insights

The Azure-native monitoring solution for .NET apps.

builder.Services.AddApplicationInsightsTelemetry();
Enter fullscreen mode Exit fullscreen mode

Out of the box:

  • Request traces with duration and status codes
  • Dependency tracking (SQL queries, HTTP calls, queues)
  • Exception capture with full stack traces
  • Live Metrics for real-time monitoring
  • Custom events and metrics
_telemetryClient.TrackEvent("OrderCreated", new Dictionary<string, string>
{
    { "OrderId", orderId.ToString() },
    { "CustomerId", customerId }
});
Enter fullscreen mode Exit fullscreen mode

Health Checks

builder.Services.AddHealthChecks()
    .AddDbContextCheck<AppDbContext>()
    .AddUrlGroup(new Uri("https://api.external.com/health"), "ExternalApi");

app.MapHealthChecks("/health");
Enter fullscreen mode Exit fullscreen mode

Part 2: Debugging Performance Issues

The Dashboard Problem — Millions of Records

The most common real-world performance issue.

// ❌ The problem — loading all 4 million rows into memory
public async Task<List<OrderDto>> GetDashboardOrders()
{
    return await db.Orders
        .Select(o => new OrderDto(o.Id, o.Product, o.Total))
        .ToListAsync(); // 4,200,000 rows loaded. Memory spikes. Timeout.
}
Enter fullscreen mode Exit fullscreen mode

The fix has multiple layers.

Layer 1: Pagination

public async Task<PagedResult<OrderDto>> GetDashboardOrders(int page, int pageSize = 50)
{
    var total = await db.Orders.CountAsync();
    var items = await db.Orders
        .OrderByDescending(o => o.CreatedAt)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o => new OrderDto(o.Id, o.Product, o.Total))
        .AsNoTracking()
        .ToListAsync();

    return new PagedResult<OrderDto>(items, total, page, pageSize);
}
Enter fullscreen mode Exit fullscreen mode

Layer 2: Projection — select only what you need

// ✅ Only the columns needed
.Select(o => new OrderDto(o.Id, o.Product, o.Total))

// ❌ Loads entire object graph
.Include(o => o.Lines)
Enter fullscreen mode Exit fullscreen mode

Layer 3: AsNoTracking() for read-only queries

.AsNoTracking() // Skips EF change tracking overhead
Enter fullscreen mode Exit fullscreen mode

Layer 4: Compiled Queries for hot paths

private static readonly Func<AppDbContext, string, Task<List<OrderDto>>> GetOrdersByCustomer =
    EF.CompileAsyncQuery((AppDbContext db, string customerId) =>
        db.Orders
            .Where(o => o.CustomerId == customerId)
            .Select(o => new OrderDto(o.Id, o.Product, o.Total))
            .AsNoTracking());

var orders = await GetOrdersByCustomer(db, customerId);
Enter fullscreen mode Exit fullscreen mode

SQL Query Plans — Finding Slow Queries

Most performance problems are SQL problems, not C# problems.

Step 1: Log EF Core SQL output

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());
Enter fullscreen mode Exit fullscreen mode

Step 2: Run the query in SSMS

Press Ctrl+M to include the actual execution plan. Look for:

  • Full table scans — no index is being used
  • Key lookups — covering index needed
  • Sort operators — missing index on ORDER BY column
  • Hash joins on large tables — join column not indexed

Step 3: Add missing indexes

CREATE NONCLUSTERED INDEX IX_Orders_CustomerId_CreatedAt
ON Orders (CustomerId, CreatedAt DESC)
INCLUDE (Product, Total, Status);
Enter fullscreen mode Exit fullscreen mode

Or via EF Core migrations:

modelBuilder.Entity<Order>()
    .HasIndex(o => new { o.CustomerId, o.CreatedAt })
    .IsDescending(false, true);
Enter fullscreen mode Exit fullscreen mode

BenchmarkDotNet — Measuring C# Performance

[MemoryDiagnoser]
public class OrderMappingBenchmark
{
    private List<Order> _orders = Enumerable.Range(0, 10_000)
        .Select(i => new Order { Id = i, Product = "Widget", Total = 9.99m })
        .ToList();

    [Benchmark]
    public List<OrderDto> ManualMapping()
        => _orders.Select(o => new OrderDto(o.Id, o.Product, o.Total)).ToList();

    [Benchmark]
    public List<OrderDto> AutoMapperMapping()
        => _mapper.Map<List<OrderDto>>(_orders);
}
Enter fullscreen mode Exit fullscreen mode
dotnet run -c Release
Enter fullscreen mode Exit fullscreen mode

The N+1 Query Problem

The most common EF Core performance issue.

// ❌ N+1 — one query for orders, then one per order for customer
var orders = await db.Orders.ToListAsync();
foreach (var order in orders)
{
    var customer = await db.Customers.FindAsync(order.CustomerId); // 1 query per order!
}

// ✅ Single query with Include
var orders = await db.Orders
    .Include(o => o.Customer)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

Memory Profiling — Diagnosing Leaks

Signs of a memory problem:

  • Gen 2 GC collections increasing over time
  • Memory usage that never drops after load
  • OutOfMemoryException under sustained traffic

Tools: dotMemory (JetBrains), Visual Studio Diagnostic Tools, PerfView

Quick in-code check:

_logger.LogInformation(
    "GC Gen0={Gen0} Gen1={Gen1} Gen2={Gen2} TotalMemory={TotalMB}MB",
    GC.CollectionCount(0),
    GC.CollectionCount(1),
    GC.CollectionCount(2),
    GC.GetTotalMemory(false) / 1024 / 1024);
Enter fullscreen mode Exit fullscreen mode

Interview-Ready Summary

  • Structured logging is non-negotiable — use named parameters, not string concatenation
  • Correlation IDs link every log entry across a full request lifecycle
  • Application Insights provides out-of-the-box tracing, exceptions, and dependencies
  • The dashboard performance fix: paginate + project + AsNoTracking() + indexes
  • Never load millions of rows — use .Skip().Take() or cursor-based pagination
  • Check SQL execution plans for table scans and missing indexes
  • BenchmarkDotNet for C# micro-benchmarks
  • N+1 queries are the most common EF Core trap — fix with Include() or projection

A strong interview answer:

"For production debugging, structured logging with correlation IDs and Application Insights are the foundation — they let you trace any request across the system. For performance issues like the dashboard problem, the fix is almost always: don't load what you don't need. That means pagination with Skip/Take, projection to select only required columns, AsNoTracking for read-only queries, and checking the SQL execution plan for missing indexes. The N+1 query is the most common EF Core trap — fixed with eager loading or projecting into a join."


Add-On — The Production Debugging Checklist

When something goes wrong in production, follow this order:

  1. Check logs — is there an exception? What's the correlation ID?
  2. Check Application Insights — is it a slow dependency (SQL, HTTP)?
  3. Check resource metrics — is CPU/memory/connection pool exhausted?
  4. Reproduce in staging — enable detailed EF logging, check SQL plans
  5. Add targeted logging — deploy a fix that adds more visibility
  6. Benchmark if needed — BenchmarkDotNet for hot-path issues
  7. Fix, measure, verify — confirm the fix with before/after metrics

Top comments (0)