Rate limiting in Postgres rather than in memory
In-memory rate limiting does not function on serverless. State moved to the database.
ADR-013 — Rate limiting in Postgres rather than in memory
Context#
The proposal share page exposes unauthenticated write endpoints — view recording and the duration beacon. Without limits, a single share token could be used to flood the proposal_views table.
The first implementation used an in-memory sliding window. This does not work on Vercel. Each serverless invocation may run in a separate instance with its own memory, and instances are recycled constantly. Counters do not accumulate across requests and vanish between them. The limiter appeared to work in local single-process tests and enforced effectively nothing in production.
This is a general failure mode worth recording: any state an in-memory implementation depends on is absent on serverless. It applies to caches, counters, sessions, and locks alike.
Decision#
Move rate limit state into Postgres, in the proposals schema:
proposals.rate_limit_buckets (
key text primary key,
count integer not null default 0,
window_start timestamptz not null default now()
)
Atomic increment via INSERT ... ON CONFLICT (key) DO UPDATE, resetting count when window_start falls outside the window. Keyed separately on share token and on IP hash. Rows older than the window are pruned on write.
Rejected: Upstash Redis. Correct for the job and faster, but adds a service, a second set of credentials, and another failure domain — for a system whose entire write volume is a handful of proposal views per week. Postgres already exists and is sufficient at this scale.
Consequences#
- Limits are actually enforced across invocations
- One database round-trip added per tracking request — negligible at this volume
- Table requires periodic pruning, handled on write
- Correctness verified across processes, not within one
Revisit when#
Tracking write volume makes per-request database round-trips material, or a system needs sub-millisecond limiting. Neither is close.