# Rate limiting in ASP.NET Core

> Fixed window, sliding window, token bucket, and concurrency limiters, each with a live demo you can tweak.

Published 2026-10-09, updated 2026-10-09, by Sanam Pakuwal. Tags: dotnet, aspnetcore, rate-limiting. Canonical URL: https://sanam.me/blog/rate-limiting-aspnetcore/

Rate limiting caps how many requests one client can make in a window. It stops a bursty caller from starving everyone else, and it keeps a slow backend from falling over. ASP.NET Core ships four limiters in `Microsoft.AspNetCore.RateLimiting`, and each one below has a live demo you can change.

## The four limiters

| Limiter | What it counts | Good for |
| --- | --- | --- |
| Fixed window | Requests in a window that resets | Simple per-user caps |
| Sliding window | Requests in a rolling window of segments | Fewer bursts at window edges |
| Token bucket | Tokens that refill at a set rate | Bursts with a steady average |
| Concurrency | Requests running at the same time | Slow, expensive endpoints |

Fixed window is the simplest. Sliding window smooths the edge where one window ends and the next starts. Token bucket lets a client burst up front, then settle at the refill rate. Concurrency ignores time and caps work in flight.

## Which one should I use?

| You want | Use |
| --- | --- |
| A simple per-user cap, and edge bursts are fine | Fixed window |
| The same, but without edge bursts | Sliding window |
| Bursts up front, then a steady average | Token bucket |
| A cap on work in flight, ignoring time | Concurrency |

Start with fixed window. Move to sliding or token bucket only when the edge burst or the burst shape actually causes a problem.

## Setup

Add the limiter to the service collection, then add the middleware. Attach a policy to an endpoint with `RequireRateLimiting`.

```csharp
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

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

    options.AddFixedWindowLimiter("fixed", o =>
    {
        o.PermitLimit = 4;
        o.Window = TimeSpan.FromSeconds(10);
        o.QueueLimit = 2;
    });
});

var app = builder.Build();

app.UseRateLimiter();

app.MapGet("/", () => "ok").RequireRateLimiting("fixed");

app.Run();
```

Requests past the limit get `429` by default. `QueueLimit` lets a few extra requests wait in a queue instead of failing straight away.

## Fixed window

A fixed window allows `PermitLimit` requests inside each `Window`, then resets the counter when the window ends. The reset is the weakness: a client can send `PermitLimit` requests at the end of one window and `PermitLimit` more at the start of the next, for double the limit in one burst.

<div class="demo" id="fw-demo">
  <div class="demo-controls">
    <label class="demo-control">Permit limit
      <input type="range" id="fw-limit" min="1" max="10" value="4">
      <span class="demo-value" id="fw-limit-val">4</span>
    </label>
    <label class="demo-control">Window (s)
      <input type="range" id="fw-window" min="2" max="10" value="4">
      <span class="demo-value" id="fw-window-val">4</span>
    </label>
    <button class="demo-button" id="fw-send">Send request</button>
  </div>
  <div class="demo-meter" id="fw-meter"><div class="demo-meter-fill" id="fw-fill"></div></div>
  <div class="demo-timer"><div class="demo-timer-fill" id="fw-timer"></div></div>
  <div class="demo-stat"><span id="fw-stat">0 of 4 used</span><span id="fw-reset"></span></div>
  <div class="demo-log" id="fw-log"></div>
</div>

<script>
window.demo = (function () {
  function stamp() {
    var n = Date.now();
    return new Date(n).toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(n % 1000).padStart(3, '0');
  }
  function log(el, text, cls) {
    var d = document.createElement('div');
    d.className = cls;
    d.textContent = text;
    el.prepend(d);
    while (el.children.length > 60) el.removeChild(el.lastChild);
  }
  // Run fn every frame, but only while the demo is on screen and the tab is visible.
  // Limiter state is computed from timestamps, so pausing never changes the result.
  function loop(root, fn) {
    var visible = !('IntersectionObserver' in window);
    var frame = 0;
    function tick(now) { fn(now); frame = requestAnimationFrame(tick); }
    function update() {
      var run = visible && !document.hidden;
      if (run && !frame) frame = requestAnimationFrame(tick);
      if (!run && frame) { cancelAnimationFrame(frame); frame = 0; }
    }
    if (!visible) {
      new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; update(); }).observe(root);
    }
    document.addEventListener('visibilitychange', update);
    update();
  }
  // Mirror a slider into its label and paint the filled part of its track.
  function range(input, label, onInput) {
    function paint() {
      label.textContent = input.value;
      input.style.setProperty('--fill', ((input.value - input.min) / (input.max - input.min)) * 100 + '%');
    }
    input.addEventListener('input', function () { paint(); if (onInput) onInput(); });
    paint();
  }
  function progress(el, p) {
    el.style.setProperty('--p', Math.max(0, Math.min(1, p)));
  }
  // Restart the nudge even when two requests are rejected back to back.
  function reject(root) {
    root.classList.remove('is-rejected');
    void root.offsetWidth;
    root.classList.add('is-rejected');
  }
  function text(el, value) {
    if (el.textContent !== value) el.textContent = value;
  }
  return { stamp: stamp, log: log, loop: loop, range: range, progress: progress, reject: reject, text: text };
})();
</script>

<script>
(function () {
  var root = document.getElementById('fw-demo');
  var limitEl = document.getElementById('fw-limit');
  var windowEl = document.getElementById('fw-window');
  var meter = document.getElementById('fw-meter');
  var fill = document.getElementById('fw-fill');
  var timer = document.getElementById('fw-timer');
  var stat = document.getElementById('fw-stat');
  var reset = document.getElementById('fw-reset');
  var logEl = document.getElementById('fw-log');
  var used = 0;
  var windowStart = performance.now();
  function limit() { return Number(limitEl.value); }
  function windowMs() { return Number(windowEl.value) * 1000; }
  function roll(now) {
    var elapsed = now - windowStart;
    if (elapsed >= windowMs()) {
      used = 0;
      windowStart = now - (elapsed % windowMs());
    }
  }
  function render(now) {
    roll(now);
    var left = windowMs() - (now - windowStart);
    demo.progress(fill, used / limit());
    demo.progress(timer, left / windowMs());
    meter.classList.toggle('is-full', used >= limit());
    demo.text(stat, used + ' of ' + limit() + ' used');
    demo.text(reset, 'resets in ' + (left / 1000).toFixed(1) + 's');
  }
  document.getElementById('fw-send').addEventListener('click', function () {
    var now = performance.now();
    roll(now);
    if (used < limit()) {
      used++;
      demo.log(logEl, demo.stamp() + '  allowed  (' + used + '/' + limit() + ')', 'allowed');
    } else {
      demo.log(logEl, demo.stamp() + '  rejected  (429)', 'rejected');
      demo.reject(root);
    }
    render(now);
  });
  demo.range(limitEl, document.getElementById('fw-limit-val'));
  demo.range(windowEl, document.getElementById('fw-window-val'), function () {
    used = 0;
    windowStart = performance.now();
  });
  demo.loop(root, render);
})();
</script>

```csharp
options.AddFixedWindowLimiter("fixed", o =>
{
    o.PermitLimit = 4;
    o.Window = TimeSpan.FromSeconds(10);
    o.QueueLimit = 2;
});
```

## Sliding window

A sliding window splits the `Window` into `SegmentsPerWindow` segments and rolls them forward. The count is the sum of the segments, so a burst spreads out instead of hitting the edge of a fixed window. It costs more memory: one counter per segment.

<div class="demo" id="sw-demo">
  <div class="demo-controls">
    <label class="demo-control">Permit limit
      <input type="range" id="sw-limit" min="1" max="12" value="6">
      <span class="demo-value" id="sw-limit-val">6</span>
    </label>
    <label class="demo-control">Segments
      <input type="range" id="sw-segs" min="2" max="6" value="4">
      <span class="demo-value" id="sw-segs-val">4</span>
    </label>
    <button class="demo-button" id="sw-send">Send request</button>
  </div>
  <div class="sw-segments" id="sw-segments"></div>
  <div class="demo-stat"><span id="sw-stat">0 of 6 used</span><span id="sw-next"></span></div>
  <div class="demo-log" id="sw-log"></div>
</div>

<script>
(function () {
  var root = document.getElementById('sw-demo');
  var limitEl = document.getElementById('sw-limit');
  var segsEl = document.getElementById('sw-segs');
  var wrap = document.getElementById('sw-segments');
  var stat = document.getElementById('sw-stat');
  var next = document.getElementById('sw-next');
  var logEl = document.getElementById('sw-log');
  var windowMs = 8000;
  var segs = [0, 0, 0, 0];
  var cells = [];
  var lastShift = performance.now();
  function limit() { return Number(limitEl.value); }
  function period() { return windowMs / segs.length; }
  function total() { return segs.reduce(function (a, b) { return a + b; }, 0); }
  // Build the columns once per segment count. Shifts only change numbers, so the bars ease
  // to their new heights instead of being rebuilt.
  function build() {
    wrap.textContent = '';
    cells = segs.map(function () {
      var col = document.createElement('div');
      col.className = 'sw-seg';
      var fill = document.createElement('div');
      fill.className = 'sw-seg-fill';
      var clock = document.createElement('div');
      clock.className = 'sw-seg-clock';
      var count = document.createElement('span');
      count.className = 'sw-seg-count';
      col.append(fill, clock, count);
      wrap.appendChild(col);
      return { col: col, fill: fill, clock: clock, count: count };
    });
  }
  function shift(now) {
    while (now - lastShift >= period()) {
      segs.shift();
      segs.push(0);
      lastShift += period();
    }
  }
  function render(now) {
    shift(now);
    var per = Math.max(1, Math.ceil(limit() / segs.length));
    var elapsed = (now - lastShift) / period();
    cells.forEach(function (c, i) {
      var current = i === cells.length - 1;
      demo.progress(c.fill, segs[i] / per);
      demo.progress(c.clock, current ? elapsed : 0);
      c.col.classList.toggle('is-current', current);
      demo.text(c.count, String(segs[i]));
    });
    demo.text(stat, total() + ' of ' + limit() + ' used');
    demo.text(next, 'oldest drops in ' + (((1 - elapsed) * period()) / 1000).toFixed(1) + 's');
  }
  document.getElementById('sw-send').addEventListener('click', function () {
    var now = performance.now();
    shift(now);
    if (total() < limit()) {
      segs[segs.length - 1]++;
      demo.log(logEl, demo.stamp() + '  allowed  (' + total() + '/' + limit() + ')', 'allowed');
    } else {
      demo.log(logEl, demo.stamp() + '  rejected  (429)', 'rejected');
      demo.reject(root);
    }
    render(now);
  });
  demo.range(limitEl, document.getElementById('sw-limit-val'));
  demo.range(segsEl, document.getElementById('sw-segs-val'), function () {
    var n = Number(segsEl.value);
    var kept = segs.slice(-n);
    while (kept.length < n) kept.unshift(0);
    segs = kept;
    build();
  });
  build();
  demo.loop(root, render);
})();
</script>

```csharp
options.AddSlidingWindowLimiter("sliding", o =>
{
    o.PermitLimit = 6;
    o.Window = TimeSpan.FromSeconds(10);
    o.SegmentsPerWindow = 4;
});
```

## Token bucket

A token bucket holds up to `TokenLimit` tokens and refills at `TokensPerPeriod` every `ReplenishmentPeriod`. Each request takes one token. A client can burst until the bucket is empty, then it is capped at the refill rate.

<div class="demo" id="tb-demo">
  <div class="demo-controls">
    <label class="demo-control">Bucket size
      <input type="range" id="tb-size" min="1" max="20" value="10">
      <span class="demo-value" id="tb-size-val">10</span>
    </label>
    <label class="demo-control">Refill per s
      <input type="range" id="tb-rate" min="1" max="10" value="2">
      <span class="demo-value" id="tb-rate-val">2</span>
    </label>
    <button class="demo-button" id="tb-send">Send request</button>
  </div>
  <div class="demo-meter" id="tb-meter"><div class="demo-meter-fill" id="tb-fill" style="--p: 1"></div></div>
  <div class="demo-stat"><span id="tb-stat">10.0 of 10 tokens</span><span id="tb-rate-stat"></span></div>
  <div class="demo-log" id="tb-log"></div>
</div>

<script>
(function () {
  var root = document.getElementById('tb-demo');
  var sizeEl = document.getElementById('tb-size');
  var rateEl = document.getElementById('tb-rate');
  var meter = document.getElementById('tb-meter');
  var fill = document.getElementById('tb-fill');
  var stat = document.getElementById('tb-stat');
  var rateStat = document.getElementById('tb-rate-stat');
  var logEl = document.getElementById('tb-log');
  var tokens = 10;
  var last = performance.now();
  function size() { return Number(sizeEl.value); }
  function rate() { return Number(rateEl.value); }
  function refill(now) {
    tokens = Math.min(size(), tokens + (rate() * (now - last)) / 1000);
    last = now;
  }
  function render(now) {
    refill(now);
    demo.progress(fill, tokens / size());
    meter.classList.toggle('is-full', tokens < 1);
    demo.text(stat, tokens.toFixed(1) + ' of ' + size() + ' tokens');
    demo.text(rateStat, '+' + rate() + ' per second');
  }
  document.getElementById('tb-send').addEventListener('click', function () {
    var now = performance.now();
    refill(now);
    if (tokens >= 1) {
      tokens -= 1;
      demo.log(logEl, demo.stamp() + '  allowed  (' + tokens.toFixed(1) + ' left)', 'allowed');
    } else {
      demo.log(logEl, demo.stamp() + '  rejected  (429)', 'rejected');
      demo.reject(root);
    }
    render(now);
  });
  demo.range(sizeEl, document.getElementById('tb-size-val'), function () {
    tokens = Math.min(tokens, size());
  });
  demo.range(rateEl, document.getElementById('tb-rate-val'));
  demo.loop(root, render);
})();
</script>

```csharp
options.AddTokenBucketLimiter("token", o =>
{
    o.TokenLimit = 10;
    o.ReplenishmentPeriod = TimeSpan.FromSeconds(1);
    o.TokensPerPeriod = 2;
    o.AutoReplenishment = true;
});
```

## Concurrency

A concurrency limiter caps how many requests run at once. It ignores how long each request takes and how many arrive in a window. Use it for endpoints that are expensive to run in parallel, like a call to an LLM or a report build.

<div class="demo" id="cc-demo">
  <div class="demo-controls">
    <label class="demo-control">Max concurrent
      <input type="range" id="cc-limit" min="1" max="8" value="3">
      <span class="demo-value" id="cc-limit-val">3</span>
    </label>
    <button class="demo-button" id="cc-send">Start request</button>
  </div>
  <div class="cc-slots" id="cc-slots"></div>
  <div class="cc-running" id="cc-running"></div>
  <div class="demo-stat"><span id="cc-stat">0 of 3 running</span></div>
  <div class="demo-log" id="cc-log"></div>
</div>

<script>
(function () {
  var root = document.getElementById('cc-demo');
  var limitEl = document.getElementById('cc-limit');
  var slotsEl = document.getElementById('cc-slots');
  var runningEl = document.getElementById('cc-running');
  var stat = document.getElementById('cc-stat');
  var logEl = document.getElementById('cc-log');
  var jobs = [];
  var n = 0;
  function limit() { return Number(limitEl.value); }
  function renderSlots() {
    slotsEl.textContent = '';
    for (var i = 0; i < Math.max(limit(), jobs.length); i++) {
      var slot = document.createElement('span');
      slot.className = 'cc-slot' + (i < jobs.length ? ' is-busy' : '');
      slotsEl.appendChild(slot);
    }
    demo.text(stat, jobs.length + ' of ' + limit() + ' running');
  }
  function render(now) {
    jobs.slice().forEach(function (job) {
      var p = (now - job.started) / job.duration;
      demo.progress(job.fill, p);
      if (p < 1) return;
      jobs.splice(jobs.indexOf(job), 1);
      job.chip.classList.add('is-done');
      setTimeout(function () { job.chip.remove(); }, 250);
      demo.log(logEl, demo.stamp() + '  completed  (req ' + job.id + ')', 'allowed');
      renderSlots();
    });
  }
  document.getElementById('cc-send').addEventListener('click', function () {
    if (jobs.length >= limit()) {
      demo.log(logEl, demo.stamp() + '  rejected  (429, at limit)', 'rejected');
      demo.reject(root);
      return;
    }
    n++;
    var chip = document.createElement('div');
    chip.className = 'cc-chip';
    var label = document.createElement('span');
    label.className = 'cc-chip-label';
    label.textContent = 'req ' + n;
    var bar = document.createElement('span');
    bar.className = 'cc-chip-bar';
    var fill = document.createElement('span');
    fill.className = 'cc-chip-fill';
    bar.appendChild(fill);
    chip.append(label, bar);
    runningEl.appendChild(chip);
    jobs.push({ id: n, chip: chip, fill: fill, started: performance.now(), duration: 1200 + Math.random() * 1800 });
    demo.log(logEl, demo.stamp() + '  started  (req ' + n + ')', 'allowed');
    renderSlots();
  });
  demo.range(limitEl, document.getElementById('cc-limit-val'), renderSlots);
  renderSlots();
  demo.loop(root, render);
})();
</script>

```csharp
options.AddConcurrencyLimiter("concurrency", o =>
{
    o.PermitLimit = 4;
    o.QueueLimit = 2;
});
```

## Per-client limits

The limiters above are global. To limit per user or per IP, return a partition with a key.

```csharp
options.AddPolicy("per-ip", context =>
    RateLimitPartition.GetFixedWindowLimiter(
        context.Connection.RemoteIpAddress?.ToString() ?? "anon",
        _ => new FixedWindowRateLimiterOptions
        {
            PermitLimit = 20,
            Window = TimeSpan.FromMinutes(1)
        }));
```

Each key gets its own counter, so one client hitting the limit does not block the rest.

## Rejection responses

Return something useful instead of a bare 429. The `OnRejected` callback sets the status, the `Retry-After` header, and a body.

```csharp
options.OnRejected = async (context, cancellationToken) =>
{
    context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
    context.HttpContext.Response.Headers.RetryAfter = "10";
    await context.HttpContext.Response.WriteAsync(
        "Slow down. Try again later.", cancellationToken);
};
```

---

Markdown version of https://sanam.me. Site guide for agents: https://sanam.me/llms.txt. MCP server: https://sanam.me/mcp.
