Skip to main content

Command Palette

Search for a command to run...

The Bulkhead Pattern in ASP.NET Core: When to Use It and How

Updated
10 min read

Distributed systems fail - not in dramatic, total collapses, but in slow, cascading ways. One upstream dependency gets slow. Its callers back up. Their thread pools fill. And before long, an outage in your payment provider takes down your product search endpoint too. In production, I've seen this failure mode bite teams that had retry logic, circuit breakers, and health checks in place - but no resource isolation between their critical and non-critical operations. The bulkhead pattern is what fills that gap.

The concepts in this article connect directly to how production resilience is layered across an entire API. If you want to see it in context - alongside circuit breakers, retry policies, rate limiting, and timeout handling wired into a running ASP.NET Core application - Chapter 10 of the Zero to Production course walks through exactly that, with source code you can run immediately.

ASP.NET Core Web API: Zero to Production

The full worked implementation - including the resource partitioning logic, integration with typed HttpClients, and the edge cases that only surface under load - is on Patreon, where the complete production-ready source maps to what we ship at Coding Droplets.

What Problem Does the Bulkhead Pattern Solve?

The name comes from ship design. A bulkhead is a watertight partition in a ship's hull - if one compartment floods, the others stay dry and the ship stays afloat. The same principle applied to software: partition your resources so that a failure or slowdown in one consumer cannot exhaust shared resources and drag down unrelated consumers.

In a typical ASP.NET Core API, every inbound request competes for the same thread pool. If one category of requests - say, calls to a slow third-party email API - starts holding threads for 30 seconds instead of 300ms, those threads are not available for serving product catalog queries or health checks. Your API becomes unresponsive not because your own code is broken, but because shared resource exhaustion propagates the failure sideways.

The bulkhead pattern prevents this by allocating a dedicated, bounded pool of concurrency to each category of work. If the email API threads fill up, those requests queue or fail fast - but the thread pool for product catalog requests is untouched.

When Should You Use the Bulkhead Pattern?

The bulkhead is the right tool when you have heterogeneous workloads competing for shared resources and you need to guarantee that low-priority or high-risk operations cannot starve critical ones.

Apply it when:

  • External dependencies have unpredictable latency. Payment gateways, SMS providers, and third-party enrichment APIs are the classic examples. Their response times can vary by 10x depending on their load.
  • A subset of your endpoints carry significantly higher business value. Your checkout flow should not fail because your recommendation service is backed up.
  • You have clear separation between read and write paths. Write operations typically involve more I/O and take longer - isolating them from read concurrency protects your read latency profile.
  • You are running in a microservice or modular monolith architecture. The more services you depend on, the more cascades you are exposed to.

Do not reach for the bulkhead when all your operations are roughly equivalent in priority and latency profile. Adding bulkhead policies to a homogeneous workload adds configuration overhead without a meaningful protection gain.

How the Bulkhead Pattern Works in .NET

In .NET, the bulkhead pattern is implemented through concurrency limiters - typically either a semaphore or Polly's ConcurrencyLimiter resilience strategy (formerly called BulkheadPolicy in Polly v7).

The core idea is simple: wrap each category of outbound calls (or inbound handler paths) in a limiter that enforces a maximum number of concurrent executions. Requests that exceed that cap either wait in a queue - up to a configured queue depth - or fail immediately with a BulkheadRejectedException (Polly v7) or a BrokenCircuitException-equivalent in v8.

With Polly v8 and Microsoft.Extensions.Resilience (the standard approach in .NET 9/10), you define a named resilience pipeline per dependency category:

builder.Services.AddResiliencePipeline("email-sender", pipeline =>
{
    pipeline.AddConcurrencyLimiter(new ConcurrencyLimiterOptions
    {
        PermitLimit = 10,
        QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
        QueueLimit = 5
    });
});

This limits the email-sender pipeline to 10 concurrent executions with a queue of 5. When all 10 slots are occupied and the queue is full, the 16th request fails immediately rather than blocking a shared thread.

Bulkhead in Action - Critical vs Non-Critical Paths

The most effective bulkhead configuration I have seen in production is a two-tier split: a large pool for critical paths and a small, tightly bounded pool for non-critical ones.

// Critical path - product catalog and checkout
builder.Services.AddResiliencePipeline("critical", pipeline =>
{
    pipeline.AddConcurrencyLimiter(permitLimit: 50, queueLimit: 20);
});

// Non-critical path - recommendations and notifications  
builder.Services.AddResiliencePipeline("non-critical", pipeline =>
{
    pipeline.AddConcurrencyLimiter(permitLimit: 8, queueLimit: 3);
});

The trade-off that bit us in an early version: setting queueLimit too high. A large queue sounds safe but it is not - it means requests wait longer before getting a fast failure, which extends the time your caller is holding its resources waiting for an answer. For degradable features like recommendations, a queue limit of 2-5 is usually correct. Let it fail fast and return a degraded response rather than hold the caller hostage.

Combining Bulkhead with Circuit Breaker

The bulkhead and the circuit breaker solve complementary problems. The bulkhead limits the blast radius of a slow dependency by capping concurrency. The circuit breaker detects that a dependency is broken and stops sending requests to it entirely. You almost always want both.

The standard ordering in a Polly v8 pipeline:

  1. Retry - for transient faults (innermost)
  2. Circuit Breaker - to stop hammering a broken service
  3. Timeout - per-request time limit
  4. Bulkhead (Concurrency Limiter) - resource isolation (outermost)

The bulkhead sits outermost because it should protect your resources before any retry amplification. If you put retry inside the bulkhead, a single slow request can consume one slot and burn multiple retries. Placing the concurrency limiter outside means it sees each original request attempt, not each retry.

For a detailed walkthrough of circuit breaker configuration specifically, see The Circuit Breaker Pattern in ASP.NET Core.

Applying Bulkhead to Typed HttpClients

In practice, most bulkhead policies in ASP.NET Core protect outbound HTTP calls. Typed HttpClients are the natural pairing point.

builder.Services.AddHttpClient<IEmailClient, EmailClient>()
    .AddResilienceHandler("email-client", pipeline =>
    {
        pipeline.AddConcurrencyLimiter(permitLimit: 10, queueLimit: 3);
        pipeline.AddCircuitBreaker(new CircuitBreakerStrategyOptions
        {
            FailureRatio = 0.5,
            SamplingDuration = TimeSpan.FromSeconds(30),
            MinimumThroughput = 5,
            BreakDuration = TimeSpan.FromSeconds(15)
        });
    });

Note the AddResilienceHandler extension from Microsoft.Extensions.Http.Resilience (part of the .NET resilience ecosystem). It binds the pipeline directly to the named HttpClient registration, so the policy applies transparently to every call made through IEmailClient. See The 12-Point IHttpClientFactory Checklist for ASP.NET Core for the broader set of practices around typed clients.

Anti-Patterns to Avoid

One global bulkhead for everything. This is no bulkhead at all - it just caps total API concurrency. You need per-dependency or per-category limits, not a single global semaphore.

Setting permit limits too high. A limit of 200 on a service that can handle 20 concurrent requests provides no real protection. Calibrate against the dependency's known capacity, not your API's throughput.

Treating all errors as bulkhead errors. A BrokenCircuitException and a timeout exception have different root causes. Log the rejection reason explicitly so your observability pipeline can distinguish "dependency slow" from "dependency down."

Forgetting to handle the rejection. If you apply a bulkhead policy and the caller does not handle IsolationRejectedException (Polly v8), the exception propagates to your global error handler and returns a 500. For degradable features, catch it explicitly and return a graceful fallback response.

What Is the Bulkhead Pattern in ASP.NET Core?

The bulkhead pattern partitions application resources - typically concurrent execution slots - into isolated pools, one per dependency or workload category. It prevents a slow or failing dependency from exhausting shared thread or connection pools and causing failures in unrelated parts of the application.

In ASP.NET Core, it is most commonly implemented using Polly's ConcurrencyLimiter strategy via Microsoft.Extensions.Resilience, applied per named HttpClient or per named resilience pipeline.

Trade-offs and When Not to Use It

The bulkhead adds configuration and operational overhead. You need to tune permitLimit and queueLimit per dependency, and those numbers need to be revisited as your traffic profile changes. Getting them wrong - too tight and you create artificial throttling under normal load; too loose and the protection is meaningless under real stress.

For simple APIs with a handful of dependencies and a flat traffic profile, a well-tuned circuit breaker + timeout policy is often sufficient. The bulkhead pays off when your application is complex enough that you have genuinely heterogeneous workload priorities and you have measured or experienced resource contention cascades in production.

FAQ

What is the difference between bulkhead and circuit breaker in .NET?

The circuit breaker monitors failure rate and opens (stops calls) when it detects that a dependency is broken. The bulkhead limits the number of concurrent calls regardless of success or failure - it is about resource isolation, not failure detection. They solve different problems and are typically used together in the same resilience pipeline.

Does the bulkhead pattern work with inbound requests in ASP.NET Core?

Yes, but it is less common. For inbound request rate limiting (protecting your own API), ASP.NET Core's built-in rate limiting middleware (AddRateLimiter) is a more natural fit. The bulkhead pattern is most valuable applied to outbound calls to external dependencies where you cannot control the source of requests.

How do I choose the right permit limit value?

Start with the maximum sustainable concurrency of the dependency - often found in its documentation or through load testing. A safe starting point is 60-70% of that maximum, leaving headroom for burst variance. Monitor rejection rates in production and adjust upward if you see false throttling under normal load.

Is Polly's BulkheadPolicy in v7 the same as ConcurrencyLimiter in v8?

Functionally yes - both cap concurrent executions and optionally queue excess requests. The API and integration model changed significantly in Polly v8. In v8, ConcurrencyLimiter replaces BulkheadPolicy and integrates with System.Threading.RateLimiting under the hood, which provides better performance and composability with ASP.NET Core's built-in rate limiting infrastructure.

Can I apply different bulkhead limits per tenant in a multi-tenant API?

Yes, using AddResiliencePipelineRegistry with a keyed pipeline factory. Register a pipeline per tenant key and resolve the appropriate pipeline at request time. This is advanced usage - validate that per-tenant isolation genuinely matters for your threat model before adding the complexity.

What happens to requests that are rejected by the bulkhead?

In Polly v8, rejected requests throw an IsolationRejectedException. If you do not catch it, it propagates to your global exception handler as a 500. For degradable paths, catch it explicitly and return a graceful fallback - a cached result, an empty list, or a specific 503 response with a Retry-After header if appropriate.


About the Author

I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.

More from this blog

C

Coding Droplets

271 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.