10 Mistakes .NET
My shipped the feature. Tests pass. Staging looks clean. You hit deploy and go home feeling good.
10 Mistakes .NET Developers Make in Production The hard lessons nobody wants to learn the expensive way

My shipped the feature. Tests pass. Staging looks clean. You hit deploy and go home feeling good.
Then at 2 AM, My phone lights up.
Production is crawling. Memory is spiking. Users are seeing timeouts. The on-call engineer is in panic mode.
Sound familiar? These aren’t just horror stories — they’re rites of passage for .NET developers. The gap between code that works in development and code that survives production is wider than most people expect, and the mistakes that cause the worst incidents are rarely the obvious ones.
Here are ten mistakes .NET developers repeatedly make in production — what causes them, what the symptoms look like, and how to fix them before they cost Me.
1. Not Understanding the Cost of async void
async void is one of the most quietly dangerous patterns in C#. It feels harmless — you see a method that doesn't need to return anything, so you write async void DoSomething() and move on. In production, it can silently swallow exceptions and cause your application to crash without a meaningful stack trace.
The core issue is exception handling. When an exception is thrown inside an async void method, it gets raised on the synchronization context — not the calling thread. You can't try/catch around a call to an async void method. The exception propagates to the thread pool, and in ASP.NET Core, that means an unhandled exception that can bring down the entire process.
// ❌ Dangerous - exceptions are unobservable
async void FireAndForget()
{
await DoSomethingAsync(); // if this throws, good luck debugging it
}
// ✅ Safe alternative
async Task FireAndForget()
{
await DoSomethingAsync();
}
The only legitimate use of async void is event handlers, where the signature is dictated by the framework. Everywhere else, return Task.
2. Blocking on Async Code with .Result or .Wait()
This one causes deadlocks. Not sometimes — reliably, under the right circumstances, and especially under load.
When you call .Result or .Wait() on a Task in a context with a synchronization context (like old ASP.NET, WinForms, or WPF), you block the current thread while waiting for the task to complete. But the task is trying to resume on that same synchronization context. Neither can proceed. Deadlock.
In ASP.NET Core, the synchronization context is no longer an issue, so deadlocks are less common — but blocking on tasks still hurts you by tying up thread pool threads unnecessarily, reducing throughput under load.
// ❌ Blocking — thread pool thread held hostage
var result = GetDataAsync().Result;
var data = FetchAsync().GetAwaiter().GetResult();
// ✅ Await properly - thread is freed during the await
var result = await GetDataAsync();
var data = await FetchAsync();
The fix is boring and universal: go async all the way down. If you’re hitting this because some entry point doesn’t support async, use Task.Run() with caution and understand what you're trading off.
3. Misusing HttpClient — Creating a New Instance Per Request
This one is a classic that still appears regularly in production codebases, even in 2024. HttpClient is designed to be reused. Creating a new instance for every HTTP request causes socket exhaustion — you burn through ephemeral ports faster than they can be released by the OS, eventually causing connection failures.
// ❌ New instance per request — socket exhaustion waiting to happen
public async Task<string> GetData(string url)
{
using var client = new HttpClient(); // Don't do this
return await client.GetStringAsync(url);
}
The right way is to use IHttpClientFactory, introduced in .NET Core 2.1. It manages the lifetime of HttpMessageHandler instances properly, handles DNS refresh, and gives you named/typed clients with configured defaults.
// ✅ Injected via IHttpClientFactory
public class DataService
{
private readonly HttpClient _client;
public DataService(IHttpClientFactory factory)
{
_client = factory.CreateClient("DataApi");
}
public async Task<string> GetData(string url)
{
return await _client.GetStringAsync(url);
}
}
Register it in your DI container with services.AddHttpClient() and you're covered.
4. Ignoring Connection Pool Exhaustion in Entity Framework
EF Core abstracts away a lot of database complexity, which is great until it isn’t. One of the most common production failures with EF Core is connection pool exhaustion — running out of available database connections.
This happens when developers hold DbContext instances open for too long, don't dispose them properly, or run long-running operations that keep connections open while doing unrelated work.
// ❌ Holding DbContext open while doing slow work
var dbContext = new AppDbContext();
var users = dbContext.Users.ToList();
await SlowExternalApiCall(); // connection held open the entire time
ProcessUsers(users);
DbContext should have a scoped lifetime in web apps (one per request). Use using statements in non-web contexts. Avoid loading large datasets and processing them while the context is still alive.
Also, beware of N+1 queries. Loading a list and then accessing a navigation property in a loop will fire a separate SQL query for each row. Use .Include() eagerly or .AsSplitQuery() for complex includes.
// ❌ N+1: fires a query per order
var customers = await db.Customers.ToListAsync();
foreach (var c in customers)
Console.WriteLine(c.Orders.Count); // lazy load per customer
// ✅ Single query with include
var customers = await db.Customers.Include(c => c.Orders).ToListAsync();
5. Not Configuring Logging Levels Properly in Production
This mistake goes in two directions: too much logging, or too little.
Too much logging — specifically, leaving Debug or Trace level enabled in production — creates a storm of log entries that buries meaningful signals, hammers I/O, and can actually degrade application performance. It also inflates your logging costs if you're using a cloud provider.
Too little logging — especially swallowing exceptions with empty catch blocks or logging at the wrong severity — means when something breaks, you have nothing to diagnose it with.
// ❌ Swallowing exceptions silently
try
{
await ProcessPayment(order);
}
catch (Exception)
{
// nothing here — this is a black hole
}
// ✅ At minimum, log what broke and where
catch (Exception ex)
{
_logger.LogError(ex, "Payment processing failed for order {OrderId}", order.Id);
throw; // or handle appropriately
}
Use structured logging (Serilog, NLog, or the built-in ILogger) with proper sinks and set your appsettings.Production.json to Warning or Error for noisy namespaces. Use log levels deliberately: Debug for development investigation, Information for business-significant events, Warning for recoverable issues, Error for failures that need attention.
6. Treating Configuration as Read-Once at Startup
Hardcoding configuration values or reading appsettings.json only at startup seems harmless — until you need to change a feature flag, connection string, or rate limit without redeploying.
The IOptions<T> pattern in .NET is powerful and often underused. It offers three variants:
IOptions<T>— reads once at startup, cached for the app's lifetimeIOptionsSnapshot<T>— re-reads per request (good for per-request config)IOptionsMonitor<T>— live updates, reacts to config file changes
If you’re running on Kubernetes or cloud infrastructure where config is injected via environment variables or a config service, you want your app to pick up changes without a full restart.
// ✅ Live-reloading config with IOptionsMonitor
public class FeatureService
{
private readonly IOptionsMonitor<FeatureFlags> _flags;
public FeatureService(IOptionsMonitor<FeatureFlags> flags)
{
_flags = flags;
}
public bool IsEnabled(string feature)
{
return _flags.CurrentValue.EnabledFeatures.Contains(feature);
}
}
Also: never store secrets in appsettings.json. Use environment variables, Azure Key Vault, AWS Secrets Manager, or whatever your cloud provider offers.
7. Not Implementing Resilience Patterns for External Calls
Your application does not exist in isolation. It calls databases, APIs, message queues, and cloud services — all of which will fail. Not sometimes. Eventually. And how your application handles those failures is often the difference between a degraded experience and a full outage.
The most common mistake is making external calls with no timeout, no retry, and no circuit breaker. A single downstream service hanging for 30 seconds can exhaust your thread pool if enough requests pile up.
The Polly library (now integrated into .NET 8 as Microsoft.Extensions.Resilience) makes resilience policies straightforward:
// ✅ Retry with exponential backoff + circuit breaker via IHttpClientFactory + Polly
services.AddHttpClient<IPaymentService, PaymentService>()
.AddStandardResilienceHandler(); // .NET 8+
// Or manually with Polly:
services.AddHttpClient<IPaymentService, PaymentService>()
.AddTransientHttpErrorPolicy(p =>
p.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))))
.AddCircuitBreakerPolicy(...);
Define a timeout for every external call. Use retries with exponential backoff and jitter for transient failures. Use circuit breakers to stop hammering a service that’s clearly down. Use fallbacks to return degraded-but-useful responses.
8. Ignoring Memory Allocation Patterns
Garbage collection in .NET is excellent, but it is not free. Excessive short-lived object allocation puts pressure on the GC, causes frequent Gen0 collections, and under load can cause Gen2 collections — which are stop-the-world pauses that users feel as latency spikes.
Common culprits in .NET production code:
String concatenation in loops. Each + creates a new string object. Use StringBuilder or string interpolation (which the compiler optimizes) for known-length strings, and ValueStringBuilder or Span<char> for performance-critical paths.
LINQ on hot paths. LINQ is readable and great for most use cases, but it allocates enumerators and intermediate collections. In tight loops or high-throughput code, prefer direct for loops or Span<T>.
Boxing value types. Passing an int or struct to a method that accepts object boxes it — allocating a heap object. Use generics to avoid this.
Unnecessary async state machines. Every async method creates a state machine. For simple passthrough methods that just return the result of one await, consider returning the Task directly without awaiting (only safe when you're not inside a using block or try/catch).
Use dotnet-counters, dotnet-trace, or a profiler like JetBrains dotMemory to identify allocation hotspots before they become production incidents.
9. Deploying Without Health Checks and Graceful Shutdown
This mistake is invisible until you do a rolling deployment and your load balancer starts sending traffic to an instance that isn’t ready yet, or until a pod gets terminated mid-request because Kubernetes didn’t know it needed to finish what it was doing.
.NET has built-in support for both.
Health checks let your orchestrator (Kubernetes, Azure App Service, etc.) know whether your application is ready to receive traffic and whether it’s still alive:
// In Program.cs
builder.Services.AddHealthChecks()
.AddSqlServer(connectionString)
.AddRedis(redisConnection)
.AddUrlGroup(new Uri("https://external-api.com/health"), "External API");
app.MapHealthChecks("/health/ready"); // readiness
app.MapHealthChecks("/health/live"); // liveness
Graceful shutdown ensures that when your app receives a SIGTERM, it stops accepting new requests and finishes in-flight ones before exiting:
// Default host shutdown timeout is 5 seconds — extend it
builder.Services.Configure<HostOptions>(opts =>
{
opts.ShutdownTimeout = TimeSpan.FromSeconds(30);
});
Also use IHostedService or BackgroundService properly — override StopAsync to clean up background work when the app shuts down.
10. Not Monitoring What Actually Matters
You can’t fix what you can’t see. Yet many .NET applications reach production with no observability — no metrics, no distributed tracing, no alerting on meaningful signals. When something goes wrong, engineers are left reading logs line by line, reconstructing what happened.
Modern .NET has first-class OpenTelemetry support. Instrument your app properly:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter())
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
But instrumentation is only half the battle. You also need to define what “good” looks like and alert when it deviates. The four golden signals are a useful framework: latency (how long requests take), traffic (request rate), errors (error rate), and saturation (how close to capacity you are).
Don’t just alert on infrastructure metrics like CPU and memory. Alert on business and application metrics: order failure rate, payment latency, active user sessions dropping unexpectedly. Those are the signals that tell you something is wrong before your users do.
Closing Thoughts
None of these mistakes are signs of incompetence. They’re signs of code that was written without production in mind — because production is hard to simulate, and the pressure to ship is real.
The good news is that each of these has a clear solution, and most of them are solved not by clever tricks but by discipline: using the framework as intended, thinking about failure modes, and building observable systems.
The best time to fix these is before you ship. The second best time is right now.
If this was useful, follow for more .NET deep dives. Got a production war story of your own? Drop it in the comments.
메타데이터
- post_id
- cb39bbfc75f4
- slug
- 10-mistakes-net-cb39bbfc75f4
- url
- https://medium.com/@Rajdip27/10-mistakes-net-cb39bbfc75f4
- canonical_url
- https://medium.com/@Rajdip27/10-mistakes-net-cb39bbfc75f4
- author_url
- https://medium.com/@Rajdip27
- status
- ok
- fetched_at
- 2026-07-11 07:07:53