Skip to content

JayArrowz/DistributedRateLimiter

Repository files navigation

DistributedRateLimiter

License NuGet

Distributed rate limiting for .NET backed by your existing database. No Redis, no message broker, no extra infrastructure.

Works in HTTP pipelines and background services — any code that calls an external API or shared resource can enforce a rate limit that's consistent across every running instance.


How it works

Each rate limit check is handled atomically in your existing database, so all running instances share the same state automatically — no in-memory counters that diverge across pods.

Instance A                          Instance B
│                                   │
│  POST /orders                     │  POST /orders
│  → CheckAsync("user:42", policy)  │  → CheckAsync("user:42", policy)
│  → INSERT ... ON CONFLICT         │  → INSERT ... ON CONFLICT
│    DO UPDATE SET count + 1        │    DO UPDATE SET count + 1
│  ← count = 1  ✓ allowed           │  ← count = 2  ✓ allowed
│                                   │
│  POST /orders (101st request)     │
│  → CheckAsync("user:42", policy)  │
│  ← count = 101  ✗ 429             │

The database row is the single source of truth. No synchronisation needed between instances.


Algorithms

Algorithm Best for
SlidingWindow Smooth enforcement — spreads requests evenly across time
FixedWindow Simpler and cheaper — slightly bursty at window boundaries
TokenBucket Burst-tolerant — allows short spikes up to a capacity, then enforces a steady refill rate

Projects

Project Target Description
DistributedRateLimiter.Core netstandard2.0 Interfaces, models, and service contracts — no dependencies
DistributedRateLimiter net8.0;net9.0;net10.0 Middleware, cleanup worker, and DI registration
DistributedRateLimiter.Providers.Postgres net8.0;net9.0;net10.0 PostgreSQL provider via Npgsql
DistributedRateLimiter.Providers.MsSql net8.0;net9.0;net10.0 SQL Server provider via Microsoft.Data.SqlClient
DistributedRateLimiter.Providers.MySql net8.0;net9.0;net10.0 MySQL/MariaDB provider via MySqlConnector

Quick start

1. Install packages

dotnet add package DistributedRateLimiter
dotnet add package DistributedRateLimiter.Providers.Postgres

2. Register with the host

using DistributedRateLimiter;
using DistributedRateLimiter.Core.Model;
using DistributedRateLimiter.Providers.Postgres;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbRateLimiter(
    new PostgresRateLimitStore(connectionString, tableName: "__rate_limits"),
    opts => opts.AddPolicy(new RateLimitPolicy
    {
        Name      = "api",
        Algorithm = RateLimitAlgorithm.SlidingWindow,
        Limit     = 100,
        Window    = TimeSpan.FromMinutes(1)
    }));

var app = builder.Build();

3. Add the middleware

// Limit by authenticated user, falling back to IP
app.UseDbRateLimiter(
    keySelector: ctx => ctx.User.Identity?.Name
                     ?? ctx.Connection.RemoteIpAddress?.ToString()
                     ?? "anon",
    policyName: "api");

app.MapControllers();
app.Run();

That's it. Every request now increments a shared counter in your database. When a key exceeds the limit the middleware returns 429 Too Many Requests with standard rate limit headers.


Response headers

Header Description
X-RateLimit-Limit Maximum requests allowed in the window
X-RateLimit-Remaining Requests remaining in the current window
Retry-After Seconds until the client may retry (only present on 429 responses)

Multiple policies

Register as many policies as you need — each is identified by name:

builder.Services.AddDbRateLimiter(
    new PostgresRateLimitStore(connectionString, "__rate_limits"),
    opts =>
    {
        opts.AddPolicy(new RateLimitPolicy
        {
            Name      = "authenticated",
            Algorithm = RateLimitAlgorithm.SlidingWindow,
            Limit     = 1000,
            Window    = TimeSpan.FromMinutes(1)
        });

        opts.AddPolicy(new RateLimitPolicy
        {
            Name      = "anonymous",
            Algorithm = RateLimitAlgorithm.SlidingWindow,
            Limit     = 20,
            Window    = TimeSpan.FromMinutes(1)
        });

        opts.AddPolicy(new RateLimitPolicy
        {
            Name                = "webhooks",
            Algorithm           = RateLimitAlgorithm.TokenBucket,
            BucketCapacity      = 500,
            RefillRatePerSecond = 50
        });
    });

app.UseDbRateLimiter(
    keySelector: ctx => ctx.User.Identity?.Name
                     ?? ctx.Connection.RemoteIpAddress?.ToString()
                     ?? "anon",
    policyNameSelector: ctx => ctx.User.Identity?.IsAuthenticated == true
        ? "authenticated"
        : "anonymous");

Using in background services

IDbRateLimiter lives in DistributedRateLimiter.Core and has no HTTP dependency — inject it anywhere.

Register the policy at startup alongside your other policies:

builder.Services.AddDbRateLimiter(
    new PostgresRateLimitStore(connectionString, "__rate_limits"),
    opts =>
    {
        opts.AddPolicy(new RateLimitPolicy { Name = "api", /* ... */ });

        opts.AddPolicy(new RateLimitPolicy
        {
            Name                = "payment-gateway",
            Algorithm           = RateLimitAlgorithm.TokenBucket,
            BucketCapacity      = 100,
            RefillRatePerSecond = 100
        });
    });

Then inject IDbRateLimiter and call it by name:

using DistributedRateLimiter.Core.Interface;

public class OrderProcessingWorker : BackgroundService
{
    private readonly IDbRateLimiter _limiter;
    private readonly IOrderRepository _orders;
    private readonly IPaymentGateway _payments;

    public OrderProcessingWorker(
        IDbRateLimiter limiter,
        IOrderRepository orders,
        IPaymentGateway payments)
    {
        _limiter = limiter;
        _orders = orders;
        _payments = payments;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var orders = await _orders.GetPendingAsync(ct);

            foreach (var order in orders)
            {
                // Enforce the payment gateway's per-merchant API quota
                // consistently across all running instances of this worker
                var result = await _limiter.CheckAsync(
                    key:        $"payment-gateway:{order.MerchantId}",
                    policyName: "payment-gateway",
                    ct);

                if (!result.Allowed)
                {
                    await Task.Delay(result.RetryAfter, ct);
                    continue;
                }

                await _payments.ChargeAsync(order, ct);
            }

            await Task.Delay(TimeSpan.FromSeconds(5), ct);
        }
    }
}

This is where a database-backed limiter has a meaningful advantage over in-memory alternatives — if you run 5 instances of this worker, an in-memory limiter allows 5× the intended quota. The shared database counter enforces the real limit regardless of instance count.


Cleanup

Rate limit rows accumulate over time. Register the built-in cleanup worker to purge them on a schedule:

// Default: purge rows older than 2 hours, run every 30 minutes
builder.Services.AddDbRateLimiterCleanup();

// Custom intervals
builder.Services.AddDbRateLimiterCleanup(opts =>
{
    opts.MaxAge   = TimeSpan.FromHours(1);   // set to at least your longest window
    opts.Interval = TimeSpan.FromMinutes(10);
});

RateLimitCleanupOptions lives in DistributedRateLimiter.Core.Model:

Property Default Description
MaxAge 2 hours Rows older than this are deleted. Set to at least your longest window duration
Interval 30 minutes How often the cleanup worker runs

The worker starts with a 30-second delay on boot so it doesn't fire immediately on every instance restart, and any exception during cleanup is caught and logged — it retries on the next interval rather than crashing.


Algorithms in depth

SlidingWindow

Buckets requests by key and second — all requests arriving within the same second increment a single shared row, then a rolling SUM counts all rows within the window. Provides true sliding enforcement — a burst of 100 requests is measured against the exact 60-second window ending now, not against a clock-aligned boundary. Concurrent requests within the same second are serialised by the database's row-level lock on the upsert, so no application-level advisory lock is needed.

new RateLimitPolicy
{
    Name      = "api",
    Algorithm = RateLimitAlgorithm.SlidingWindow,
    Limit     = 100,
    Window    = TimeSpan.FromMinutes(1)
}

FixedWindow

Simpler and cheaper than sliding window — one row per key, reset at each window boundary. Allows a burst of up to 2 × Limit at a boundary (the last requests of one window plus the first of the next).

new RateLimitPolicy
{
    Name      = "api",
    Algorithm = RateLimitAlgorithm.FixedWindow,
    Limit     = 100,
    Window    = TimeSpan.FromMinutes(1)
}

FixedWindow supports any positive whole-number-of-seconds window on all providers. Buckets are aligned to the Unix epoch, so a 10-second window produces buckets at 00:00:00, 00:00:10, 00:00:20, etc.

TokenBucket

Maintains a token count per key. Each request consumes one token. Tokens refill continuously at RefillRatePerSecond up to BucketCapacity. Allows short bursts while enforcing a long-run average rate.

new RateLimitPolicy
{
    Name                = "api",
    Algorithm           = RateLimitAlgorithm.TokenBucket,
    BucketCapacity      = 200,   // burst up to 200
    RefillRatePerSecond = 10     // steady-state: 10 req/s
}

Database providers

PostgreSQL

using DistributedRateLimiter.Providers.Postgres;

new PostgresRateLimitStore(connectionString, tableName: "__rate_limits")

EnsureSchemaAsync creates the table if it does not exist:

CREATE TABLE IF NOT EXISTS __rate_limits (
    key          TEXT        NOT NULL,
    window_start TIMESTAMPTZ NOT NULL,
    count        INT         NOT NULL DEFAULT 0,
    tokens       FLOAT       NULL,
    last_refill  TIMESTAMPTZ NULL,
    PRIMARY KEY (key, window_start)
);

FixedWindow and TokenBucket use a single atomic round-trip via INSERT ... ON CONFLICT DO UPDATE ... RETURNING. SlidingWindow uses a transaction with two statements — an upsert into a second-level bucket row and a rolling SUM count query. The ON CONFLICT DO UPDATE row-level lock serialises concurrent same-second requests, so no advisory lock is needed.

Required features:

Feature Minimum version
ON CONFLICT DO UPDATE (upsert) PostgreSQL 9.5
RETURNING clause PostgreSQL 8.2
DATE_TRUNC, clock_timestamp() PostgreSQL 8.1

No extensions or special server configuration required. Minimum: PostgreSQL 9.5. PostgreSQL 12+ recommended.

Theoretical throughput (single app instance, pool of 100 connections, formula: pool ÷ (latency × round-trips)):

Deployment Latency FixedWindow / TokenBucket (1 RT) SlidingWindow (4 RT)
Localhost / same host ~0.2 ms ~500,000 ~125,000
Same datacenter / LAN ~1 ms ~100,000 ~25,000
Cloud, same region ~2–5 ms ~20,000–50,000 ~5,000–12,500
Cross-region ~20–50 ms ~2,000–5,000 ~500–1,250

Round-trip breakdown:

  • FixedWindow / TokenBucket — 1 round-trip (INSERT ... ON CONFLICT DO UPDATE ... RETURNING)
  • SlidingWindow — 4 round-trips (BEGIN + upsert + count query + COMMIT)

With multiple app instances, total connections across all instances must stay below PostgreSQL's max_connections (default 100 on most managed services). Use PgBouncer in transaction mode to scale past this limit.

Package: Npgsql 8.x+. Targets net8.0, net9.0, net10.0.


SQL Server

using DistributedRateLimiter.Providers.MsSql;

new MsSqlRateLimitStore(connectionString, tableName: "__rate_limits")

EnsureSchemaAsync creates the table if it does not exist:

IF OBJECT_ID(N'[__rate_limits]', N'U') IS NULL
CREATE TABLE [__rate_limits] (
    key          NVARCHAR(512)  NOT NULL,
    window_start DATETIMEOFFSET NOT NULL,
    count        INT            NOT NULL DEFAULT 0,
    tokens       FLOAT          NULL,
    last_refill  DATETIMEOFFSET NULL,
    PRIMARY KEY (key, window_start)
);

Uses MERGE ... WITH (HOLDLOCK) for atomic upserts.

Required features:

Feature Minimum version Used by
MERGE with HOLDLOCK SQL Server 2008 All algorithms
DATETIMEOFFSET SQL Server 2008 All algorithms
OUTPUT clause on MERGE SQL Server 2008 FixedWindow, TokenBucket
SYSUTCDATETIME() SQL Server 2008 All algorithms
GREATEST() SQL Server 2022 TokenBucket only

Minimum: SQL Server 2008 for FixedWindow and SlidingWindow. SQL Server 2022+ is required for the TokenBucket algorithm due to the GREATEST() function.

Theoretical throughput (single app instance, pool of 100 connections, formula: pool ÷ (latency × round-trips)):

Deployment Latency FixedWindow / TokenBucket (1 RT) SlidingWindow (3 RT)
Localhost / same host ~0.2 ms ~500,000 ~165,000
Same datacenter / LAN ~1 ms ~100,000 ~33,000
Cloud, same region ~2–5 ms ~20,000–50,000 ~7,000–17,000
Cross-region ~20–50 ms ~2,000–5,000 ~650–1,650

Round-trip breakdown:

  • FixedWindow / TokenBucket — 1 round-trip (MERGE ... OUTPUT in a single batch)
  • SlidingWindow — 3 round-trips (BEGIN TX + MERGE; SELECT batch + COMMIT)

SQL Server's default max pool size is 100 per connection string; increase via Max Pool Size=N in the connection string.

Package: Microsoft.Data.SqlClient 6.x. Targets net8.0, net9.0, net10.0.


MySQL / MariaDB

using DistributedRateLimiter.Providers.MySql;

new MySqlRateLimitStore(connectionString, tableName: "__rate_limits")

EnsureSchemaAsync creates the table if it does not exist:

CREATE TABLE IF NOT EXISTS `__rate_limits` (
    `key`          VARCHAR(512) NOT NULL,
    `window_start` DATETIME(6)  NOT NULL,
    `count`        INT          NOT NULL DEFAULT 0,
    `tokens`       DOUBLE       NULL,
    `last_refill`  DATETIME(6)  NULL,
    PRIMARY KEY (`key`, `window_start`)
);

MySQL/MariaDB does not support RETURNING on upserts so FixedWindow and TokenBucket cost 2 round-trips instead of 1. SlidingWindow uses a multi-statement batch (INSERT ... ON DUPLICATE KEY UPDATE + SELECT) inside a READ COMMITTED transaction — InnoDB row-level locking on the upsert serialises concurrent same-second requests without an advisory lock.

Required features:

Feature Minimum version Used by
INSERT ... ON DUPLICATE KEY UPDATE MySQL 4.1 / MariaDB 5.1 All algorithms
DATETIME(6) (microsecond precision) MySQL 5.6 / MariaDB 5.3 All algorithms
GREATEST(), LEAST() MySQL 5.0 / MariaDB 5.0 TokenBucket
InnoDB storage engine MySQL 5.5 (default) / MariaDB 5.5 (default) SlidingWindow concurrency
READ COMMITTED isolation level MySQL 5.0 / MariaDB 5.0 SlidingWindow
Multi-statement queries MySQL 5.0 / MariaDB 5.0 SlidingWindow

Minimum: MySQL 5.6+ or MariaDB 5.3+. InnoDB is required for SlidingWindow; MyISAM is not supported. Multi-statement queries and READ COMMITTED isolation are supported by MySqlConnector with no additional connection string options.

Theoretical throughput (single app instance, pool of 100 connections, formula: pool ÷ (latency × round-trips)):

Deployment Latency FixedWindow / TokenBucket (2 RT) SlidingWindow (3 RT)
Localhost / same host ~0.4 ms ~125,000 ~83,000
Same datacenter / LAN ~2 ms ~25,000 ~16,000
Cloud, same region ~4–10 ms ~5,000–12,500 ~3,000–8,000
Cross-region ~20–50 ms ~1,000–2,500 ~650–1,650

Round-trip breakdown:

  • FixedWindow / TokenBucket — 2 round-trips (upsert + select, MySQL does not support RETURNING)
  • SlidingWindow — 3 round-trips (BEGIN TX + INSERT; SELECT batch + COMMIT)

Tune pool size via Max Pool Size=N in the connection string.

Package: MySqlConnector 2.x. Targets net8.0, net9.0, net10.0.


Custom provider

Implement IRateLimitStore from DistributedRateLimiter.Core:

using DistributedRateLimiter.Core.Interface;
using DistributedRateLimiter.Core.Model;

public sealed class MyCustomStore : IRateLimitStore
{
    public Task EnsureSchemaAsync(CancellationToken ct = default) { ... }

    public Task<RateLimitResult> SlidingWindowAsync(
        string key, int limit, TimeSpan window, CancellationToken ct = default) { ... }

    public Task<RateLimitResult> FixedWindowAsync(
        string key, int limit, TimeSpan window, CancellationToken ct = default) { ... }

    public Task<RateLimitResult> TokenBucketAsync(
        string key, int capacity, double refillRatePerSecond, CancellationToken ct = default) { ... }

    public Task PurgeExpiredAsync(TimeSpan maxAge, CancellationToken ct = default) { ... }
}

Pass it directly to AddDbRateLimiter:

builder.Services.AddDbRateLimiter(new MyCustomStore(), opts => { ... });

Configuration reference

RateLimitPolicy

Property Required Description
Name Unique policy identifier used in UseDbRateLimiter and CheckAsync
Algorithm SlidingWindow, FixedWindow, or TokenBucket
Limit Window algorithms Maximum requests per window
Window Window algorithms Duration of the window
BucketCapacity TokenBucket Maximum tokens (burst ceiling)
RefillRatePerSecond TokenBucket Tokens added per second

RateLimitCleanupOptions

Property Default Description
StartupStagger 30 seconds Delay after application startup before the first cleanup run
MaxAge 2 hours Minimum age of a row before it is eligible for deletion
Interval 30 minutes How often the cleanup worker runs

When to use this vs Redis

A database-backed rate limiter is a good fit when:

  • Your stack does not already include Redis
  • You need distributed enforcement across instances without adding infrastructure
  • You are already on a supported database for other purposes

Throughput guide for a single hot key (cloud, same region):

Algorithm Per-key limit Why
FixedWindow / TokenBucket ~10,000–50,000 req/s Single atomic statement, no serialisation
SlidingWindow ~5,000–12,500 req/s Pool-bound (same as tables above); row-level lock held only during the upsert, not the full transaction

For most real-world rate limit keys (per-user, per-IP, per-tenant) traffic rarely exceeds a few hundred requests per second on any single key, so all three algorithms are a practical fit. SlidingWindow becomes the bottleneck only when a single key is genuinely a hot path — in that case, switch to FixedWindow or TokenBucket, or use Redis.

Consider Redis when:

  • A single key routinely receives thousands of requests per second and you need SlidingWindow semantics
  • You need sub-millisecond enforcement latency
  • You are already running Redis for caching or pub/sub

Packages

 
 
 

Contributors

Languages