DEV Community

Hakim Saoudi
Hakim Saoudi

Posted on

A rate limiter changes when it leaves memory

A rate limiter changes when it leaves memory

Rate limiting can start with a Map and a counter. That is enough for a
single process. The design changes when two workers need to enforce the same
limit.

Imagine a limit of 100 requests per minute for one user. Worker A sees 60
requests. Worker B sees 60 more. Each worker stays below the limit, but the
user has sent 120 requests. The application has enforced two local limits, not
one shared limit.

That is the point where storage becomes part of rate-limiting behaviour. The
backend affects the result you return, the latency of the check, and the way
the service behaves when the backend fails.

Memory is a local policy

An in-memory limiter has useful properties:

const count = requests.get(key) ?? 0

if (count >= limit) {
  return new Response("Too Many Requests", { status: 429 })
}

requests.set(key, count + 1)
Enter fullscreen mode Exit fullscreen mode

The application adds no network dependency. A check stays inside the process,
which makes the implementation easy to test and cheap to run.

That limiter protects one process. Restart the process and the state vanishes.
Add a second worker and each worker starts with its own view of traffic. That
may be the intended policy for a small service. It becomes a correctness bug
when the application presents one shared limit to its users.

The first question is therefore simple: should all workers see the same state?
If the answer is no, memory may be enough. If the answer is yes, the limiter
needs a shared backend.

A shared backend changes the request path

Redis is a strong default for a shared limiter that sits on a busy request path.
It keeps the state close to the operation and supports atomic updates through
Lua scripts. The cost is a network call, connection management, and a new
failure domain.

PostgreSQL fits a different situation. An application that already depends on
PostgreSQL may prefer to keep rate-limit state in the same operational system.
UPSERTs and transactions can coordinate updates, but hot keys can create row
contention. A database call on every request also deserves a clear latency
budget.

My starting defaults are concrete:

  • use memory for one process, local development, and limits that are meant to stay local;
  • use Redis when several workers share a busy limit;
  • use PostgreSQL when it already belongs to the service and the limiter does not sit on its hottest path.

These defaults can change with the workload. Write down the trade-off before
choosing an adapter.

Failure behaviour belongs to the design

A shared limiter can fail while the application is handling a request. The
service needs a policy for that case.

Consider a Redis timeout. A retry may recover from a short network problem, but
it also adds latency to the user request. A circuit breaker can stop repeated
calls to a failing backend, but the limiter still needs a decision while the
circuit is open. A fallback can keep the endpoint available, but an
allowing fallback may remove the protection that the limiter was supposed to
provide.

Endpoint risk should decide the policy. A public read endpoint and a login
endpoint do not carry the same risk. A service may choose an allowing fallback
for one route and a rejecting fallback for another. That decision belongs in
the route's design, not in an undocumented default inside the storage adapter.

The same applies to a local denial cache. It can prevent repeated blocked
requests from reaching Redis, but it cannot replace shared state. It reduces
pressure on the backend. It does not make several workers agree about a new
request.

Keep the limiter API stable

The application should express its policy through a small contract: check one
identifier, check a batch, and clear state when needed. The storage adapter can
change behind that contract.

That is the direction I am taking with RateLock. The project provides local,
Redis, and PostgreSQL adapters with the same core checking model. The Redis
adapter uses Lua scripts for atomic operations. The PostgreSQL adapter uses
UPSERTs. The local adapter keeps state in memory and adds no runtime service.

The application keeps the same shape while the deployment moves from one
process to several workers. The operational differences stay visible in the
adapter and its configuration.

The decision I would make first

Before comparing four algorithms or running a benchmark, write down three
constraints:

  1. The number of processes that must share the limit.
  2. The fallback behaviour during a backend timeout.
  3. The latency budget for one check.

Those answers narrow the storage choice faster than a feature table. They also
give you a test plan. You can test two workers against one key, inject a Redis
failure, and measure the result that the route returns.

Rate limiting starts as a counter. Once several workers share traffic, the
counter becomes a distributed-systems decision. Choose the storage and failure
policy with the endpoint in front of you.

RateLock's documentation and examples are available at
https://ratelock-docs.vercel.app/.

Top comments (0)