On an IIS-hosted ASP.NET Core app, the expensive work is rarely the middleware pipeline—it is the SQL round-trip, the connection checked out of the pool, and the thread held while the query runs. A single scripted client hammering a public search or login endpoint can fill Microsoft.Data.SqlClient’s pool, push other requests into timeouts, and make the whole site look “down” even though the app pool is still running.

ASP.NET Core’s built-in rate-limiting middleware (.NET 7 onward, including .NET 10) lets you reject that traffic at the edge of your app with a 429 before controllers, filters, or EF Core execute. On Windows/IIS in-process hosting that is often enough: one worker process, one set of limits, immediate relief for the database.

#Where to put the limiter on IIS

Register rate limiting in the service container and place UseRateLimiter early—after exception handling and HTTPS redirection, before authentication-heavy work you still want limited, and definitely before MapControllers / MapGroup. Health-check paths used by load balancers or monitoring should be excluded so probes are not throttled.

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = async (ctx, token) =>
    {
        ctx.HttpContext.Response.Headers.RetryAfter = "30";
        await ctx.HttpContext.Response.WriteAsync(
            "Too many requests. Try again shortly.", token);
    };

    // Per-IP fixed window for anonymous API traffic
    options.AddPolicy("api-ip", httpContext =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 60,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0
            }));
});

var app = builder.Build();
app.UseRateLimiter();
// ... auth, endpoints

Attach the policy to endpoints or groups so only the routes that touch SQL or external APIs pay the cost of tracking:

csharp
app.MapGet("/api/products/search", SearchProductsAsync)
   .RequireRateLimiting("api-ip");

var account = app.MapGroup("/api/account")
    .RequireRateLimiting("api-ip");
account.MapPost("/login", LoginAsync);

#Pick a partition key that matches IIS reality

Fixed-window and sliding-window limiters need a stable partition key. On IIS, Connection.RemoteIpAddress is correct only when you are not accidentally reading a proxy hop. For in-process ANCM hosting with clients hitting the site binding directly, the remote address is usually the real client. If you terminate TLS on a reverse proxy or CDN in front of IIS, enable forwarded headers and key off the forwarded client address—otherwise every visitor shares one IP and one budget.

  • Anonymous read APIs: partition by IP (or forwarded client IP).
  • Authenticated write APIs: partition by user id claim so one account cannot burn the whole IP bucket on a NAT.
  • Login and password-reset: stricter fixed window (for example 10/minute per IP) separate from general API limits.
  • Exclude /health and /ready so IIS Application Initialization and external monitors stay green.

Example of a tougher login policy alongside the general API policy:

csharp
options.AddPolicy("login", httpContext =>
    RateLimitPartition.GetFixedWindowLimiter(
        httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
        _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 10,
            Window = TimeSpan.FromMinutes(1),
            QueueLimit = 0
        }));

#IIS and process boundaries

In-process hosting (the default ASP.NET Core Module path on modern IIS) keeps Kestrel inside w3wp.exe. Memory-backed limiters then apply per app pool worker. That matches most shared and single-server Windows VPS layouts: one pool, one counter store, predictable behavior after recycle.

Know the edges. An app pool recycle resets counters—fine for abuse control, weak as a long-term quota system. Overlapping recycle can briefly run two workers, each with its own counts. Web-garden (multiple worker processes in one pool) splits the budget; prefer a single worker unless you have a measured reason not to. Multi-server farms need a distributed store (for example a shared cache) if you require a global cap; for “stop this IP from melting this box’s SQL pool,” the built-in memory limiter is the right tool.

Return 429 with a Retry-After header and a short body. Log rejections at Information with the partition key so you can tell a bot blast from a misconfigured integration. Keep QueueLimit at 0 for public APIs under attack—you want fast rejection, not a backlog of threads waiting on SQL.

#Tie limits to SQL pool math

Rate limits should be stricter than your SqlConnection pool can absorb under worst-case query time. If Max Pool Size is 100 and a hot endpoint averages 200 ms under load, sustained concurrency above a few dozen already risks queue waits. A 60 requests/minute/IP cap on search will not replace indexing—but it stops one client from checking out dozens of connections in a tight loop while legitimate traffic still gets through.

Combine with normal hosting hygiene: fail fast on cancellation, set command timeouts deliberately, and avoid sync-over-async in controllers. The limiter is the first brake; pool and query design remain the drivetrain.

#Practical takeaway

Add a fixed-window rate limiter partitioned by client IP (or user id) on every ASP.NET Core endpoint that hits SQL Server, exclude health checks, return 429 with Retry-After, and keep the middleware in-process on IIS so counters live with the app pool. You will cut overload-driven SqlException noise and keep connection pools available for real users—without touching web.config or waiting on a full farm-wide cache deployment.