When moving an application to ASP.NET Core 11, test its concurrency limits through HTTP before accepting the middleware replacement. Use the rate-limiting middleware to protect the relevant endpoints, then deliberately occupy its permits and queue. A successful build cannot tell you whether excess requests are rejected before expensive work starts or whether a cancelled waiter releases its queue slot.
The example below checks one process with two permits and one waiting slot. Its .NET 11 RC1 proof passed all 11 checks on Windows, covering overflow rejection, successful completion, an explicit HTTP 500 response and cancellation while queued. It establishes the behavior of the migration target; comparison with the old middleware requires a separate test.
Table of Contents
Configure the migration target
Microsoft removed the legacy Microsoft.AspNetCore.ConcurrencyLimiter implementation from ASP.NET Core 11 after deprecating it in ASP.NET Core 8. The official migration guidance recommends the rate-limiting middleware. Its System.Threading.RateLimiting.ConcurrencyLimiter remains available, so distinguish that current limiter from the removed middleware package.
This is the relevant registration and endpoint mapping from the executed proof:
using var limiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 2,
QueueLimit = 1,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
var work = new WorkGate();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("bounded-work", _ =>
RateLimitPartition.Get("one-process", _ => limiter));
});
var app = builder.Build();
app.UseRouting();
app.UseRateLimiter();
app.MapGet("/work/limited",
async Task<IResult> (HttpContext context) => await work.Execute(context))
.RequireRateLimiting("bounded-work");
app.MapGet("/work/unbounded",
async Task<IResult> (HttpContext context) => await work.Execute(context));
The fixed partition key makes every protected request in this process share the same limiter. The unbounded route supplies a negative control: it uses the same handler but has no endpoint policy. WorkGate is the test fixture shown below, and builder is the ordinary WebApplication.CreateBuilder instance; the complete project supplies the host and runner.
For an ordinary application that does not need to inspect an explicitly constructed limiter, the built-in AddConcurrencyLimiter registration is a simpler alternative. The explicit instance here lets the runner inspect the real queue and permit counters while requests are pending. Keep that instrumentation choice separate from your application’s registration and disposal design; the ASP.NET Core service lifetime and disposal guide explains those ownership decisions when moving objects into dependency injection.
Set HTTP 429 explicitly if that is your intended overflow contract. Attach the named policy to every route that needs protection, and put UseRateLimiter after UseRouting for endpoint-specific policies, as described in the rate-limiting middleware documentation. Registering the policy alone leaves an unmarked endpoint outside that policy.
Test concurrency limits with held handlers
Sending several requests to a fast endpoint can produce a misleading green test: each request may finish before the next reaches the handler. This proof holds admitted requests behind an explicit gate and observes the server’s active count before it sends the next request. Its negative control first admits four overlapping requests, showing that the client and handler can actually create contention.
The gate is a small test-only fixture:
sealed class WorkGate
{
private TaskCompletionSource gate = NewGate();
private int active, maximum, started;
public int Active => Volatile.Read(ref active);
public int Maximum => Volatile.Read(ref maximum);
public int Started => Volatile.Read(ref started);
private static TaskCompletionSource NewGate() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
public void Reset(bool hold)
{
if (Active != 0)
throw new InvalidOperationException("Work still active.");
gate = NewGate();
active = maximum = started = 0;
if (!hold) Release();
}
public void Release() => gate.TrySetResult();
public async Task<IResult> Execute(HttpContext context)
{
var count = Interlocked.Increment(ref active);
Interlocked.Increment(ref started);
int previous;
do
{
previous = Maximum;
if (previous >= count) break;
}
while (Interlocked.CompareExchange(ref maximum, count, previous)
!= previous);
try
{
await gate.Task.WaitAsync(context.RequestAborted);
return context.Request.Query.ContainsKey("fail")
? Results.StatusCode(500)
: Results.Ok(new { activeAtEntry = count });
}
finally { Interlocked.Decrement(ref active); }
}
}
Started records entry into the handler, while Active and Maximum measure overlap. Releasing the gate lets admitted work complete; the runner resets it only after the previous handlers have drained. The fixture intentionally stalls work for assertions and must stay in a local test application rather than become a production control endpoint.
The protected sequence is explicit: occupy two permits, wait for one queued request, then submit the overflow request. The runner gives each state assertion a bounded timeout, so a broken setup fails instead of hanging indefinitely:
work.Reset(hold: true);
var first = client.GetAsync("/work/limited");
var second = client.GetAsync("/work/limited");
await Until(() => work.Active == 2, "two permits occupied");
var third = client.GetAsync("/work/limited");
await Until(() => limiter.GetStatistics()?.CurrentQueuedCount == 1,
"one request queued");
using var fourth = await client.GetAsync("/work/limited");
report.Check("A full queue rejects the next request with HTTP 429",
fourth.StatusCode == HttpStatusCode.TooManyRequests,
new { status = (int)fourth.StatusCode });
report.Check("Rejected work never enters the handler", work.Started == 2,
new { started = work.Started });
work.Release();
var completed = await Task.WhenAll(first, second, third);
report.Check("Admitted requests complete with HTTP 200",
completed.All(response => response.StatusCode == HttpStatusCode.OK),
completed.Select(response => (int)response.StatusCode).ToArray());
foreach (var response in completed) response.Dispose();
This excerpt uses the proof’s Until helper, which polls every 20 milliseconds with a 10-second deadline; report.Check records the observed result. The HTTP client allows up to 16 connections to the server, above the four-request control. Neither helper manufactures concurrency: the test waits for server-side state before checking overflow.
Read the observed results
The Windows run used SDK 11.0.100-rc.1.26425.128 and ASP.NET Core 11.0.0-rc.1.26425.128. It recorded the following outcomes on 3 October 2026:
| Scenario | Observed result | What it establishes |
|---|---|---|
| Unbounded negative control | Four handlers active together | The fixture can generate overlap |
| Protected saturation | Two handlers active, one request queued | The configured admission boundary is exercised |
| Overflow | HTTP 429, handler entries remain at two | Rejected work does not enter the handler |
| Gate released | Three admitted responses are HTTP 200; peak active count is two | Admitted work completes within the tested concurrency bound |
| Successful work drains | Two permits available, queue empty | Capacity returns after completion |
| Five sequential requests | All return HTTP 200 | Completed work can repeatedly reuse the permits |
| Explicit failure response | HTTP 500, then two permits available | A completed failure response returns its permit |
| Queued request cancelled | Cancellation observed, queue empty, only two handler entries | The cancelled waiter does not enter the handler |
| Request after cancellation | HTTP 200 | The tested cancellation does not strand subsequent work |
The browser capture shows another successful execution of the same source. Its run identifier differs from the uploaded JSON receipt, so the screenshot and receipt document separate runs rather than the same execution.

The receipt also includes hashes of the project, server source, shared runner host and browser page. Those hashes matched the supplied files. Preserve the receipt alongside the source when rerunning the proof, since a green screenshot alone does not identify which code produced the result.
Check cancellation and failure precisely
The cancellation case fills both active permits, waits until the third request appears in the limiter queue, and cancels that request through its CancellationTokenSource. It checks both sides of the event: the client observes cancellation and the server’s queue count reaches zero without another handler entry. Only then does it release the active work and check that a fresh request succeeds.
The HTTP 500 case is deliberately narrower. The handler returns Results.StatusCode(500) normally; it does not throw an exception. Its result proves permit recovery after that completed response. If your migration depends on exception handling, streaming responses, client disconnects during active work or shutdown, add separate assertions for those paths before accepting the change.
Five sequential successful calls also help distinguish concurrent admission from a time-window allowance. The two-permit setting bounds overlapping work; completed requests can free capacity for later requests. Choose a separate request-rate policy when the actual requirement is a requests-per-minute contract.
Run the proof on Windows
Use the project pinned to SDK 11.0.100-rc.1.26425.128 and target net11.0. The server needs no Azure account, database or Docker installation. From the extracted project directory, unblock the downloaded scripts and start the local browser demonstration:
Get-ChildItem -Recurse -Filter *.ps1 | Unblock-File
powershell -NoProfile -ExecutionPolicy Bypass -File .\Start-Demo.ps1
Open http://127.0.0.1:5128, run the checks and download the JSON receipt. A page showing NOT_RUN is only the initial screen; the expected finished result is PASS with 11 successful checks. Stop the server with Ctrl+C in its PowerShell window when finished.
For a verification run that does not require the browser, use:
powershell -NoProfile -ExecutionPolicy Bypass -File .\Verify-Demo.ps1
That script checks the SDK, restores and builds the project, then executes the runner and saves its evidence. Keep the full failure output if any check fails; changing a timeout or deleting a negative control to obtain PASS would weaken the proof. These commands run the provided fixture, so keep its pinned source and scripts together.
Decide what must survive your migration
Before changing an existing application, inventory the endpoints and middleware that the old limiter actually protected. An endpoint policy can cover a smaller portion of the pipeline than the old placement, so compare the work admitted before and after the change. For this fixture, the asserted boundary is entry into WorkGate.Execute; it makes no claim about work performed earlier in the request pipeline.
Treat two permits and one queue slot as test values. Choose production settings from the capacity and latency budget of the resource you are protecting, then rerun the assertions with those settings. Queuing can defer a rejection while adding waiting time, so monitor both queued and active work rather than interpreting fewer overflow responses as an automatic improvement.
The fixed key shares capacity across protected requests in one process. A per-user partition would solve a different admission problem, and multiple application processes would have separate local state. This experiment contains no distributed coordination and cannot establish an application-wide cap across replicas.
The test configures OldestFirst, but one waiting request cannot establish FIFO ordering among several waiters. Applications that depend on the ordering of the old AddQueuePolicy or AddStackPolicy should run a multi-waiter comparison against their actual old package and target implementation. Avoid promising unchanged queue semantics from this result.
Finally, this is RC1 evidence. Retest the pinned proof when changing the runtime, policy or protected routes. A safe rollout preserves an independently deployable known-good application build, including its framework and package versions, until the new admission and failure behavior has been checked under representative traffic. The proof reports correctness observations; it measures no throughput, latency or denial-of-service resistance.
References
- Microsoft: ConcurrencyLimiter middleware removed in ASP.NET Core 11.
- Microsoft: Rate-limiting middleware in ASP.NET Core.
- Microsoft: System.Threading.RateLimiting.ConcurrencyLimiter API.
Demo source code: View the complete runnable example on GitHub.
Found this useful? Support more practical developer content.