Scaling
Redis sessions, multi-instance, sliding expiration trade-offs, performance.
Most apps scale fine with the default DB-backed sessions. If yours doesn't, here's the plan.
Redis sessions
The first thing to switch when login/refresh becomes a hot spot.
Sentinel / Cluster
For HA Redis:
Cluster mode (7+ nodes) works the same way — the underlying ioredis figures out routing.
Failover plan
When Redis goes down, every authenticated user loses their session simultaneously. Mitigations:
- HA setup (sentinel/cluster), so a single node loss doesn't take down auth.
- Short-circuit: in disaster mode, pre-authorize known good JWTs without checking the session. Risky — only do this for a defined recovery window.
- Per-region Redis with cross-region replication if your app is global.
Multi-instance considerations
When you run more than one app process:
- Memory sessions are out. Use DATABASE or REDIS.
- Sticky sessions are not required for either DATABASE or REDIS — both are shared state.
- Event listeners run on every instance. If your listener does something user-visible (like sending an email), make sure it's idempotent or run only one instance as the "leader" for side effects.
- The user-state cache follows your store. It is on by default for REDIS, where an invalidation reaches every instance, and off by default for DATABASE because a per-process cache would let instance B serve a deactivated account until the TTL expired. See user-state cache.
Sliding expiration trade-off
session.slidingExpiration: true is convenient — active users never get logged out. But every refresh writes the session row.
| Backend | Cost of sliding refresh |
|---|---|
| Redis | Cheap (~1ms) |
| Postgres | One UPDATE per refresh — measurable at scale |
| MySQL | Same |
For a million daily-active users with auto-refresh every 14 minutes, that's ~100M writes/day on the session table. Switch to Redis or accept fixed-window expiry (the default) if your DB feels the pressure.
JWT payload size
Big JWTs make every authenticated request more expensive. The default payload is ~400 bytes; it grows with every claim you add via customizeTokenPayload.
Targets:
- Under 1 KB total: fine.
- 1–4 KB: noticeable but workable. Watch for header size limits in middleware (some proxies cap at 8 KB).
- 4 KB+: rethink. Move data to the session row and fetch server-side.
See Customizing the JWT.
N+1 patterns
Common pitfall: loading users with all relations. Don't do this:
That's 5 queries per user. Load only what you need; for userAccesses, prefer a join with tenantId filter:
For high-traffic admin dashboards, write raw queries — TypeORM relation-loading hits the limit.
Connection pool
If you switch to Redis but keep DB-backed everything else, the DB pool no longer absorbs auth traffic. As of 2.18.0 it does not absorb the guard's own user lookup either: the three fields the guard enforces (isActive, mustChangePassword, emailVerifiedAt) are served from the user-state cache, so a warm authenticated request costs zero database queries.
What remains is whatever your code loads. If a request reads user fields the library does not cache, snapshot them into the session via customizeSessionData and read from there instead of round-tripping to the DB — the session comes from Redis anyway.
Benchmarking
Numbers from apps/example-nest on a Macbook M3 (laptop, single instance):
| Endpoint | p50 | p95 | p99 |
|---|---|---|---|
POST /auth/login (email+password, DB session) | 38 ms | 75 ms | 120 ms |
POST /auth/refresh-token (DB session, sliding off) | 4 ms | 9 ms | 18 ms |
POST /auth/refresh-token (Redis session) | 1.5 ms | 3.2 ms | 6 ms |
Authenticated GET /me (header mode) | 2 ms | 5 ms | 11 ms |
These are illustrative — measure on your own infra.