Skip to main content

How to Limit Concurrent Requests in ASP.NET Core

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

A concurrency limit lets a fixed number of requests run at once, and makes the rest wait or go away. It's different from a rate limit, which counts requests over time. It's also what kept my API alive when a burst would otherwise have crashed it.

What You Need​

The .NET 10 SDK. It's all in the framework, with no packages to add. Make a small project to try it:

Terminal
dotnet new web --no-https -n WebDemo
cd WebDemo

Step 1: Add the Limiter​

Replace Program.cs. The endpoint takes a second, so that requests overlap:

Program.cs
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status503ServiceUnavailable;

options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
RateLimitPartition.GetConcurrencyLimiter("everyone", _ => new ConcurrencyLimiterOptions
{
PermitLimit = 2,
QueueLimit = 2,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
}));
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/work", async () =>
{
await Task.Delay(1000);
return "done";
});

app.Run();

The concurrency limiter in a macOS-style code window: a global partitioned limiter with a permit limit of 2, a queue of 2 and oldest-first processing, and an endpoint that waits one second.

What each part does:

SettingMeaning
GlobalLimiterIt applies to every endpoint, unless one opts out
"everyone"The partition key. One key means one shared limit. Different keys get a limit each
PermitLimit = 2Two requests run at a time
QueueLimit = 2Two more may wait
OldestFirstThey run in the order they came
RejectionStatusCodeWhat everything else gets. The default is 503 as well, and it's clearer stated

app.UseRateLimiter() turns it on. The name's the same for rate limits and concurrency limits, because they share the middleware.

Step 2: Try It​

Run it, and send 6 requests at once from another terminal:

Terminal 1
dotnet run --no-launch-profile --urls http://localhost:5300
Terminal
seq 6 | xargs -P 6 -I{} curl -s -o /dev/null -w "%{http_code} after %{time_total}s\n" http://localhost:5300/work
Output
503 after 0.000743s
503 after 0.000757s
200 after 1.008215s
200 after 1.009553s
200 after 2.008609s
200 after 2.010941s

Six requests at once: two refused with 503 straight away, two answered 200 after one second, and two answered 200 after two seconds.

Exactly as described: two run, taking 1 second. Two wait, and take 2 seconds. Two get refused immediately.

Step 3: Keep Some Endpoints Out​

A liveness check should answer whatever the load is:

Program.cs
app.MapGet("/health", () => "Healthy").DisableRateLimiting();

The Real Use: Protecting a Connection Pool​

With MySql.Data, a request waiting for a connection blocks a thread, and a burst above Maximum Pool Size can starve the thread pool until the process ends. A request here waits without a thread, and a request only needs one connection at a time. So set the limit to the pool's size, read from the connection string so that the two can't drift apart:

Program.cs
using MySql.Data.MySqlClient;

var maximumPoolSize = (int)new MySqlConnectionStringBuilder(
builder.Configuration.GetConnectionString("Inventory")).MaximumPoolSize;

Use it as PermitLimit. The difference was stark. Without the limit, and with the default pool of 100, 150 at once ended the process in 17 of 26 runs. With it, and a pool of only 5:

Requests at onceResultTime
50All 200 answered1.42 s
150All 600 answered2.36 s
500All 2,000 answered6.37 s

Microsoft's advice stands: "Apps using rate limiting should be carefully load tested and reviewed before deploying." These limits are per process, so several copies of an app get a limit each.

Common Mistakes​

MistakeWhat happensDo this instead
A different key per requestEach gets its own limit, so nothing is limitedUse one key for a shared limit
No limit on the queueRequests pile up, and wait for agesSet QueueLimit, and answer 503 past it
Forgetting UseRateLimiterNothing happens, with no warningAdd it
Limiting health checksA busy app looks deadDisableRateLimiting() on liveness

Troubleshooting​

SymptomLikely causeFix
A 503 with no bodyNothing writes oneAdd AddProblemDetails and UseStatusCodePages
Many 503s under normal loadThe queue is too small, or something's slowRaise QueueLimit, and find what's slow
DisableRateLimiting is ignoredA UseRouting call comes after UseRateLimiterMove it before, or leave it out

Conclusion​

GetConcurrencyLimiter gives you a limit on requests in flight, a queue that holds no thread, and an immediate 503 beyond that. In front of a connection pool, it's the difference between a slow moment and a crash.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.