Most of the move from Laravel to .NET has been mechanical: different syntax, same instincts. Controllers are controllers, dependency injection is dependency injection, an ORM is an ORM. Async is where that stops working. It has no Laravel equivalent, and it's where most of the incidents I've seen actually come from.
PHP doesn't have this problem because PHP doesn't really have this concept. A request comes in, runs on its own process or thread, and there's no shared thread pool to starve. .NET's async model is built around a shared thread pool, and if you get async wrong, you're not just writing slower code. You can deadlock the request, swallow an exception the process never tells you about, or run the server out of sockets under load.
Blocking on async code
// ❌ deadlock / thread-pool starvation under load
var user = _service.GetUserAsync(id).Result;
This line looks harmless. It compiles, it works in dev, it might even work in production for a while under light load. Then traffic goes up and requests start hanging.
.Result blocks the calling thread until the async call finishes. Under load, the thread pool is busy running other requests, and the continuation for GetUserAsync is waiting for a free thread to resume on. If the pool is saturated, that continuation never gets scheduled, the blocked thread never unblocks, and you've got a deadlock. At minimum you get thread-pool starvation, where things slow down instead of failing outright, which is actually worse to diagnose.
The fix is not clever:
// ✅
var user = await _service.GetUserAsync(id, ct);
Async all the way down. The moment you mix in one blocking call, you've undone the point of using async in the first place. It's not a style preference. A single .Result or .Wait() buried three layers deep in an otherwise-async call chain is enough to bring a whole request path down under load.
async void
// ❌ exception is unobservable, process may crash
public async void Handle(Event e) { ... }
async void exists for one legitimate case: UI event handlers, where the framework calls you and there's nowhere to await. Everywhere else it's a trap. An exception thrown inside an async void method doesn't propagate the way you'd expect. It doesn't get captured by the caller's try/catch. It gets rethrown on the synchronization context, and for a lot of hosting models that means it can crash the process outright.
Compare that to an async Task method: exceptions get captured in the returned Task, and the caller can await it, catch it, log it, whatever. An async void method just fires the exception into the void.
// ✅
public async Task HandleAsync(Event e, CancellationToken ct) { ... }
Same shape, completely different failure behavior. If you're wiring up an event handler and it's not a UI callback, it should return Task.
A new HttpClient per call
// ❌ new HttpClient per call → socket exhaustion
using var http = new HttpClient();
This one's sneaky. HttpClient implements IDisposable, so the using block looks like correct, responsible code. It isn't. Disposing an HttpClient doesn't immediately release the underlying socket. It goes into a TIME_WAIT state. Do this on every request and under enough load you exhaust the available sockets on the box, and now everything making outbound HTTP calls starts failing, not just this one client.
The usual next move people make is to fix that by making the HttpClient static and reusing it forever. That solves socket exhaustion but introduces a different bug: a static HttpClient doesn't respect DNS changes, because the connection stays pinned to whatever IP it resolved first. If the downstream service moves (a failover, a DNS update, a container restart behind a load balancer), your static client keeps hammering the old address.
The actual fix is IHttpClientFactory:
// ✅
builder.Services.AddHttpClient<IPaymentClient, PaymentClient>()
.AddStandardResilienceHandler(); // retry + circuit breaker + timeout
It manages the connection pool for you, rotates handlers on a schedule so DNS changes get picked up, and AddStandardResilienceHandler() gives you retry, circuit breaker, and timeout behavior in one line. There's no manual HttpClient lifecycle to get wrong.
The rules that fall out of this
Once you've been burned by these a couple of times, they collapse into a short list I now just follow by default:
Async all the way down. One blocking call anywhere in the chain undoes the benefit of everything above it and can deadlock the request.
Take a CancellationToken in every async method and pass it on. If the client disconnects, that should stop costing you a database connection. Threading the token through every layer is tedious the first few times and then it's just muscle memory.
IHttpClientFactory, always. Manual HttpClient instances exhaust sockets. A static one misses DNS changes. There isn't a third option that's actually safe.
Nothing mutable in a singleton without a real concurrency primitive. ConcurrentDictionary, SemaphoreSlim, Channel<T>, Interlocked: pick the one that fits, but don't put a plain Dictionary or a counter on a singleton and assume it'll be fine because it worked in local testing with one user.
Background work belongs in BackgroundService/IHostedService, or better, a durable queue. Hangfire, Quartz, a real broker. Task.Run(...) and forget feels fine in dev and then dies with the pod on a rolling deploy, taking whatever it was doing with it. If the work needs to actually complete, it needs to survive a restart, and Task.Run doesn't.
For caching, HybridCache, in-process plus distributed with stampede protection, is the current default answer. IDistributedCache with Redis covers cross-instance state when you need something simpler or already have Redis in the stack.
None of these are exotic. They're all things the framework already gives you a correct way to do. The trap isn't that .NET makes async hard, it's that the wrong version of each of these compiles, runs, and looks identical to the right version until the thread pool is under real load. Laravel never put me in that position, because it never had a shared thread pool to starve.
Top comments (0)