DEV Community

Libme
Libme

Posted on

Rate Limiting Your Own API: Should the Counter Live in Redis, Postgres, or at the Edge?

If your rate limiter keeps its counter in process memory, your advertised limit is a lie as soon as you run more than one instance — four replicas means roughly four times the traffic gets through. Move the counter somewhere all instances can see: Redis if you need low latency at real volume, Postgres if you already run one and your traffic is modest, the edge if what you're stopping is volumetric abuse rather than per-customer quota. The hard part isn't the algorithm; it's deciding what happens when the counter store itself is down.

The symptom: a 100 req/min limit that lets through 400

The bug report reads like this: a customer on a "100 requests per minute" plan is clearly doing 300-plus and never sees a 429. Nothing looks broken — the limiter is running, it returns X-RateLimit-Remaining, and if you hammer it locally it works perfectly. Then you check how many app instances are running.

Most drop-in middleware defaults to an in-memory store — express-rate-limit, for instance, ships with a memory store by default and its docs are explicit that this doesn't work across multiple processes. Each process keeps its own independent bucket. With four containers behind a load balancer, a caller round-robins across four separate 100-request budgets. Nothing errors. The number is just wrong.

There's a quieter version of the same bug: one instance, but the process restarts on every deploy. Counters reset, and anyone who times their burst around a deploy gets a fresh budget. Same root cause — the counter's lifetime is tied to the process, and the process is not the unit your limit is defined over.

If your rate limit is defined per customer, the counter has to live somewhere that outlives and spans your processes.

Where can the counter actually live?

Location Accuracy across instances Added latency per request Main failure mode Best for
In-process memory None (per replica) ~0 Silently multiplies your limit Single-process tools, local dev
Redis (or Redis-compatible) Exact, atomic via Lua Sub-millisecond on the same network Redis down → fail-open or fail-closed decision Per-key quotas at real volume
Postgres Exact, atomic via upsert A write per request, plus pool contention Write amplification, autovacuum load Modest traffic, no new dependency wanted
Edge / CDN / API gateway Exact enough, but scoped to what the edge can see None to your origin Can't see app-level identity or plan tier Volumetric abuse, IP floods

These aren't mutually exclusive. The sane end state for most teams is two layers: something blunt at the edge that keeps a flood off your origin, and something identity-aware in the app that enforces the quota you actually sell.

Pick the location by what the limit is keyed on: IP-shaped limits belong at the edge, plan-shaped limits belong where your app knows who the caller is.

How do you implement a token bucket in Redis without race conditions?

The naive version — GET, check, SET — is a read-modify-write race, and under exactly the concurrency you're trying to limit, it's wrong. Do the whole thing in one Lua script so it executes atomically on the server:

-- KEYS[1]  = bucket key, e.g. "rl:tenant_42"
-- ARGV[1]  = capacity (max burst)
-- ARGV[2]  = refill rate, tokens per second
-- ARGV[3]  = current time in ms (from the caller)
-- ARGV[4]  = cost of this request, usually 1
local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill   = tonumber(ARGV[2])
local now      = tonumber(ARGV[3])
local cost     = tonumber(ARGV[4])

local state  = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(state[1])
local ts     = tonumber(state[2])

if tokens == nil then
  tokens = capacity
  ts     = now
end

local elapsed = math.max(0, (now - ts) / 1000)
tokens = math.min(capacity, tokens + elapsed * refill)

local allowed = 0
if tokens >= cost then
  tokens  = tokens - cost
  allowed = 1
end

redis.call('HSET', key, 'tokens', tokens, 'ts', now)
-- expire once a full refill would have happened anyway
redis.call('PEXPIRE', key, math.ceil((capacity / refill) * 1000) + 1000)

return { allowed, math.floor(tokens) }
Enter fullscreen mode Exit fullscreen mode

Wiring it up with ioredis, which lets you register the script once and call it like a normal command:

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

redis.defineCommand('takeToken', { numberOfKeys: 1, lua: LUA_SCRIPT });

async function allow(tenantId, { capacity = 100, refillPerSec = 100 / 60 } = {}) {
  const [allowed, remaining] = await redis.takeToken(
    `rl:${tenantId}`, capacity, refillPerSec, Date.now(), 1
  );
  return { allowed: allowed === 1, remaining };
}
Enter fullscreen mode Exit fullscreen mode

Two details matter. The timestamp comes from the application, not from TIME inside the script — that keeps it deterministic and, more usefully, testable with a fake clock. And the PEXPIRE is what keeps this from becoming an unbounded key space: idle tenants evict themselves once enough time has passed that their bucket would be full anyway.

Token bucket, not fixed window, is the default I reach for because it allows a legitimate short burst while still holding the long-run average — which is usually what a customer expects from "100 per minute."

The atomic script isn't an optimization; a rate limiter with a read-modify-write race is a rate limiter that fails precisely under load.

Can you do this in Postgres if you don't want another dependency?

Yes, and for a lot of small teams it's the right call. A fixed-window counter is a single atomic statement:

create table rate_limit_counters (
  bucket_key   text        not null,
  window_start timestamptz not null,
  hits         int         not null default 0,
  primary key (bucket_key, window_start)
);

insert into rate_limit_counters (bucket_key, window_start, hits)
values ($1, date_trunc('minute', now()), 1)
on conflict (bucket_key, window_start)
do update set hits = rate_limit_counters.hits + 1
returning hits;
Enter fullscreen mode Exit fullscreen mode

One round trip, returns the post-increment count, no race. Delete rows older than a couple of windows on a schedule and the table stays tiny.

The honest drawbacks: every request now writes to your primary database. That's WAL traffic, dead tuples, and autovacuum work proportional to request rate, and it takes a connection from the same pool your real queries use — so a traffic spike contends with itself twice. Fixed windows also allow a boundary burst: a caller can spend a full window's budget in the last second of one window and again in the first second of the next, so a "100/min" limit tolerates 200 requests inside one two-second span. You can smooth that with a sliding window (two adjacent counters, weighted by how far you are into the current window), which is worth doing if the burst actually hurts you.

Postgres is a perfectly good rate limit store right up until the limiter's write volume becomes a meaningful fraction of your database's write volume.

What belongs at the edge instead?

The edge sees a request before it costs you anything, which is exactly why it's the right place for blunt limits and the wrong place for nuanced ones. It can't tell that this caller is on the Scale plan with a quota that resets on the 3rd — that's app knowledge. It can tell that one IP is sending 5,000 requests a minute to your login endpoint.

If you want that layer managed, Cloudflare's rate limiting rules will drop volumetric abuse at the edge before it reaches your origin, at the cost of only being able to key on what a request looks like from outside your app. For serverless and edge runtimes where holding a long-lived Redis TCP connection is awkward, Upstash offers Redis over HTTP with a per-request pricing model, which sidesteps the connection-pooling problem those runtimes have. If you already terminate traffic through an API gateway, its built-in throttling usually covers the coarse layer — but check whether its counter is per-node or global, because managed gateways aren't automatically immune to the bug this post opens with.

Use the edge to protect your infrastructure and the app to enforce your contract; trying to make one layer do both is where the design gets bad.

What should the response actually say?

A 429 with an empty body teaches the caller nothing, and a client that doesn't know when to retry will just retry immediately.

  • Return 429 Too Many Requests with a Retry-After header, in seconds. This one is a real standard and well-supported client-side.
  • Add RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset if you want callers to self-pace. As of mid-2026 those fields are still an IETF draft rather than a finished RFC, and plenty of APIs use the older X-RateLimit-* spelling — pick one, document it, don't change it.
  • Decide fail-open vs fail-closed explicitly. If Redis is unreachable, does the request pass or get rejected? For a public API guarding cost, fail-closed; for an internal service where the limiter is a safety belt, fail-open with a loud alert. What you don't want is for that to be an accident of where your try/catch happens to sit.

Write the fail-open decision down in the code as a named constant, because you will only find out what you chose during an incident.

FAQ

Should I rate limit by IP address or by API key?
By API key or account ID whenever the caller is authenticated — IPs are shared by corporate NATs and mobile carriers, so IP limits punish legitimate users in groups. Use IP limits only for unauthenticated endpoints like login and signup, where there's no better identity available.

Do I need Redis to rate limit an API?
No. A single Postgres table with an upsert-and-return counter is atomic and correct, and it's the right choice if your request volume is well below your database's write capacity. Move to Redis when the limiter's writes start showing up in your database's load profile.

What's the difference between 429 and 503 for rate limiting?
Return 429 when this specific caller exceeded their quota — it's about them, and Retry-After tells them when to come back. Return 503 when your service as a whole is shedding load; 429 implies the caller can fix the problem by slowing down, 503 doesn't.

Bottom line

If you're running more than one instance, the in-memory limiter you installed on day one is not enforcing the number you think it is — verify that first, before you tune anything. Small teams with an existing Postgres and moderate traffic should start with the upsert counter and skip the extra dependency entirely. Reach for Redis with an atomic Lua token bucket when per-request database writes stop being free, and add an edge rule on top when the traffic you're fighting is a flood rather than a customer over quota. Whichever you choose, write the 429 response and the store-is-down behavior deliberately, because those two things are what your callers and your on-call actually experience.

Related reading

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev

Rate limiting is really a product policy disguised as infrastructure. Redis, Postgres, and edge counters each make different promises about precision, latency, fairness, and failure behavior.