NestJS Rate Limiting: In-Memory vs Redis vs Postgres
Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com. I shipped a NestJS API with the default @nestjs/throttler storage last year and only noticed the hole when we ran two instances behind a
Cross-posted from my blog. The original, with any updates, lives on fabwaseem.com.
I shipped a NestJS API with the default @nestjs/throttler storage last year and only noticed the hole when we ran two instances behind a load balancer. Each process kept its own counter, so a client that should have been cut off at 100 requests in a minute quietly got closer to 200. Today I re-tested that on NestJS 12 with three storages on the same box: in-memory, Redis, and Postgres.
What I tested
Everything ran on one 8 vCPU / 16 GB Linux VM on November 6, 2026:
-
Node.js 24.22.1, NestJS 12.3.3,
@nestjs/throttler6.7.1 -
Redis 7.2.11 with persistence off, via
@nestjs-redis/throttler-storage2.0.1 and theredis5.12.1 client -
PostgreSQL 18.6 with a small custom storage that upserts into one
throttler_hitstable - Limit: 100 requests / 60 seconds, tracker keyed on
x-forwarded-forso localhost tests don't all share127.0.0.1
I measured three things: p50/p95 latency of allowed requests, whether request 101 returns 429, and whether two Nest processes behind the same storage still stop at 100 total.
If you're comparing Nest to thinner frameworks, I wrote about Hono vs Express and the NestJS 12 upgrade separately. Redis vs Postgres as infrastructure shows up again in my pg-boss vs BullMQ and BullMQ pipeline posts.
Wiring @nestjs/throttler
The default setup is tiny. Global guard, one named throttler:
ThrottlerModule.forRoot({
throttlers: [{ name: 'default', ttl: seconds(60), limit: 100 }],
getTracker: async (req) => {
const xf = req.headers['x-forwarded-for'];
if (typeof xf === 'string' && xf.length) return xf.split(',')[0].trim();
return req.ip;
},
}),
// providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }]
That uses the built-in in-memory storage. Fine for a single process. Wrong the moment you scale out.
Redis storage
I used @nestjs-redis/throttler-storage, which implements ThrottlerStorage with a Lua script so the increment is atomic:
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
ThrottlerModule.forRoot({
throttlers: [{ name: 'default', ttl: seconds(60), limit: 100 }],
storage: new RedisThrottlerStorage(redis),
getTracker: /* same as above */,
});
Pass an existing client. The package does not manage connect/disconnect for you.
Postgres storage (custom)
There isn't an official Nest Postgres throttler storage, so I wrote one. One row per key, upsert that resets when the window expires:
CREATE TABLE throttler_hits (
key text PRIMARY KEY,
hits integer NOT NULL DEFAULT 0,
expires_at timestamptz NOT NULL,
is_blocked boolean NOT NULL DEFAULT false,
block_expires_at timestamptz
);
// inside increment():
INSERT INTO throttler_hits (key, hits, expires_at)
VALUES ($1, 1, now() + ($2 || ' milliseconds')::interval)
ON CONFLICT (key) DO UPDATE SET
hits = CASE WHEN throttler_hits.expires_at <= now() THEN 1
ELSE throttler_hits.hits + 1 END,
expires_at = CASE WHEN throttler_hits.expires_at <= now()
THEN now() + ($2 || ' milliseconds')::interval
ELSE throttler_hits.expires_at END
RETURNING hits, expires_at, is_blocked, block_expires_at;
I wrap that in a transaction and set is_blocked when hits > limit, matching how the in-memory storage behaves. No SKIP LOCKED here: a single-row upsert is enough for a counter. SKIP LOCKED is the pattern I use for job claiming, not for rate-limit tallies.
Latency (allowed requests)
Eighty requests under the limit, median of three runs. Raw Node http with no Nest is the baseline.
| Storage | p50 (ms) | p95 (ms) |
|---|---|---|
| none (raw http) | 0.34 | 0.57 |
| memory | 0.36 | 0.64 |
| redis | 0.38 | 0.81 |
| postgres | 1.00 | 1.42 |
On localhost, memory and Redis are basically free. Postgres adds about 0.6 ms at p50 versus the raw baseline. That is one round trip plus the upsert. Across a network to a remote Postgres the gap will be larger; across a network to Redis it will grow too, just usually less.
Reject correctness
Single process, LIMIT + 20 requests with the same client IP:
| Storage | 200s | 429s | First 429 at request # |
|---|---|---|---|
| memory | 100 | 20 | 101 |
| redis | 100 | 20 | 101 |
| postgres | 100 | 20 | 101 |
All three cut off exactly after 100. So the bug is not "throttler is wrong". The bug is sharing.
Multi-instance correctness
Two Nest processes, same Redis or Postgres (or none for memory), alternating requests for the same IP, LIMIT + 30 = 130 total:
| Storage | Allowed | Blocked | Shared limit held? |
|---|---|---|---|
| memory | 130 | 0 | No |
| redis | 100 | 30 | Yes |
| postgres | 100 | 30 | Yes |
Memory allowed every request. Each process only saw 65 hits, both under 100. That is the load-balancer failure mode. Redis and Postgres stopped at 100 total across both processes.
What I'd pick
- One instance, local or edge: in-memory. Lowest moving parts. Don't pretend it will survive a second replica.
-
Already running Redis (BullMQ, cache, sessions): use
@nestjs-redis/throttler-storage. Latency matched memory on this box and shared correctly. - Postgres only, no Redis: the custom upsert storage works. Expect roughly a millisecond of overhead on localhost, more over the network. Keep the table tiny and index-free beyond the primary key.
I would not put rate-limit counters in the same hot transactional tables as orders or payments. A dedicated table (or Redis) keeps the lock footprint small.
One caveat: this is one machine, client and storage on localhost, Nest 12.3.3, throttler 6.7.1. It answers "does shared storage actually share" and "how much does each storage add on this box". It does not answer global multi-region limits or abuse at the CDN layer. Put a WAF or edge limit in front when that matters.
If you only remember one number from today: memory let 130 through where Redis and Postgres stopped at 100. That is the whole reason to change the storage.
FAQ
Does NestJS @nestjs/throttler work across multiple instances?
Only if you swap the default in-memory storage for a shared one. With two Nest processes and a limit of 100, in-memory allowed all 130 alternating requests in my test because each process only saw 65. Redis and Postgres both stopped at 100 total.
What Redis storage should I use with @nestjs/throttler?
I used @nestjs-redis/throttler-storage 2.0.1 with the redis 5.x client on NestJS 12. You pass an existing Redis client; the package runs the increment in a Lua script so it stays atomic.
Can I store NestJS rate limits in Postgres?
Yes, with a custom ThrottlerStorage. I used a single throttler_hits table and an INSERT ... ON CONFLICT upsert that resets hits when the TTL window expires. On localhost it added about 0.6 ms at p50 versus a raw HTTP baseline.
How much latency does NestJS rate limiting add?
On one 8 vCPU box with localhost storage, allowed-request p50 was 0.36 ms for in-memory, 0.38 ms for Redis 7.2.11, and 1.00 ms for PostgreSQL 18.6, against a 0.34 ms raw HTTP baseline. Remote Redis or Postgres will cost more.
Is in-memory rate limiting OK for production NestJS?
It is fine for a single instance. The moment you run two replicas behind a load balancer, each keeps its own counter, so clients can exceed the limit. Switch to Redis or Postgres (or an edge/WAF limit) before you scale out.
Originally published on fabwaseem.com.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.