A Postgres rate limiter for serverless Next.js, without Redis: one upsert, tested across four instances
A Postgres rate limiter for serverless Next.js without Redis: one atomic upsert, tested across four processes. In-memory let 80 through, Postgres 20.
The kit's rate limiter is a Map in memory, and its own header comment is honest about that: "correct for a single Node process", while "on serverless or multi-instance deploys each instance keeps its own counters". It suggests swapping the internals for a shared store such as Upstash Redis. Most apps built on the kit already run a Postgres database for auth and billing, so before adding another service I built the shared version as a Postgres rate limiter for serverless Next.js, without Redis. Then I tested whether it holds when four processes hit it at the same moment. It does, and the in-memory version, measured the same way, let four times the limit through.
Why the Map leaks
The design of the limiter itself (fixed window, keyed by user or IP, checked before the model call) is in rate limiting LLM API routes in Next.js. The problem here is narrower: every server instance has its own Map. To make that concrete, I started four Node processes at once, each standing in for a serverless instance. Each fired 50 concurrent calls at the kit's rateLimit("user:demo") with the default 20 per minute. Each process allowed 20. 80 requests got through a limit of 20, and on a deploy that scales to more instances the leak scales with it.
One table, one upsert
The shared version needs one table: a row per key per window. In Drizzle:
/** Shared fixed-window rate limit counters: one row per key per window. */
export const rateLimits = pgTable(
"rate_limits",
{
key: text("key").notNull(),
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
count: integer("count").notNull().default(0),
},
(table) => [primaryKey({ columns: [table.key, table.windowStart] })],
);
npm run db:generate turns that into a CREATE TABLE with CONSTRAINT "rate_limits_key_window_start_pk" PRIMARY KEY("key","window_start"). The limiter is then a single statement:
export async function rateLimit(
key: string,
opts?: { limit?: number; windowMs?: number },
): Promise<RateLimitResult> {
const limit = opts?.limit ?? 20;
const seconds = (opts?.windowMs ?? 60_000) / 1000;
const [row] = await db().execute<{ count: number; reset_at: string }>(sql`
insert into rate_limits (key, window_start, count)
values (${key}, to_timestamp(floor(extract(epoch from now()) / ${seconds}) * ${seconds}), 1)
on conflict (key, window_start) do update set count = rate_limits.count + 1
returning count, window_start + make_interval(secs => ${seconds}) as reset_at`);
return {
ok: row.count <= limit,
remaining: Math.max(0, limit - row.count),
resetAt: new Date(row.reset_at).getTime(),
};
}
Three choices carry the correctness:
- The database computes the window. Flooring
now()to the window size means every instance uses the database's clock, not its own. Two servers whose clocks disagree by a second cannot split one minute into two rows. ON CONFLICT DO UPDATEis the lock. The first request in a window inserts the row. Every later one updates it, and Postgres serializes updates to the same row. There is no read-then-write gap for concurrent requests to slip through.RETURNINGgives back the new count in the same round trip, so the check costs exactly one query.
Tested: four instances, one counter
The same experiment as before: four processes, 50 concurrent calls each, all for one key, against the Postgres version on a throwaway Postgres 16. 20 of the 200 requests were allowed, all by whichever process got there first, and the other three processes got none. Afterwards the row's count read exactly 200, so not one concurrent increment was lost.
Sequential calls on the same machine took a median of 1.29 ms, with a p95 of 2.15 ms. In production, add the round trip to your database. If your Postgres sits in another region from your functions, that round trip now runs before every model call, and it is worth measuring.
Old windows pile up, one row per key per minute. A cleanup statement, delete from rate_limits where window_start < now() - interval '1 hour', removed 5,000 stale rows in one go in the same test. Run it from a cron job, or from a small fraction of limiter calls.
Things that bite
- It is async now. The in-memory
rateLimitreturns a result. This one returns aPromise, so the chat route'sconst limit = rateLimit(...)becomesawait rateLimit(...). TypeScript flags every call site that forgets, becauselimit.okdoes not exist on aPromise. - Windows now align to the clock. The in-memory version starts a window at a key's first request. This one uses fixed minute boundaries, so a burst straddling the boundary can get up to twice the limit within a few seconds. That is a weakness of every fixed window, and a sliding window needs more state per key.
- One key is one row lock. A user's own requests queue on their row, which is fine. A global key, such as one limit for the whole app, would make every request in the app wait on a single row.
- Decide what happens without the database. The kit's metering and quota helpers fail open when no database is configured, so the public demo keeps working. Keep the in-memory version as the fallback when
hasDatabase()is false, or the limiter becomes the thing that takes the demo down. - Each check borrows a pooled connection. It is brief, but it counts against the same pool as everything else, as covered in Postgres connection pooling for serverless Next.js.
Shipwright, the Next.js and Claude starter kit this blog documents, ships the in-memory limiter behind a single function, so this is one file swap, one migration and an await at the call site. The Postgres version is not in the kit today.