3.2 KiB
| name | description | type |
|---|---|---|
| map-ratelimiter-memory-leak | In-memory Map rate limiters grow unbounded in long-running Node.js servers — need setInterval eviction with .unref() | concept |
In-Memory Map Rate Limiter — Memory Leak and Eviction Pattern
The Pattern
A token-bucket rate limiter using a JavaScript Map is a common, dependency-free implementation:
const buckets = new Map<string, { tokens: number; lastRefill: number }>()
export function rateLimit(ip: string): boolean {
const now = Date.now()
let bucket = buckets.get(ip)
if (!bucket) {
bucket = { tokens: MAX_TOKENS, lastRefill: now }
buckets.set(ip, bucket)
}
// refill logic...
return bucket.tokens > 0
}
The Problem
Every unique IP address creates a new Map entry. Entries are never deleted. In a long-running server this means:
- Each unique client = permanent memory allocation
- After weeks of production traffic, thousands of stale entries occupy RAM
- In a high-traffic scenario (CDN scraping, bot traffic) this becomes a slow memory leak that eventually causes OOM
The Map also holds strong references, preventing GC of the bucket objects.
The Fix — setInterval with .unref()
const REFILL_INTERVAL_MS = 60_000 // 1 minute
// Evict stale buckets that haven't been touched in 2 refill cycles
setInterval(() => {
const cutoff = Date.now() - REFILL_INTERVAL_MS * 2
for (const [ip, bucket] of buckets) {
if (bucket.lastRefill < cutoff) {
buckets.delete(ip)
}
}
}, REFILL_INTERVAL_MS).unref()
// ^^^^^^^^
// .unref() prevents the interval from keeping the process alive
// if this is the only remaining async operation (important for tests)
Why .unref()?
setInterval holds an event loop reference. If you don't call .unref():
- Test runners (Vitest, Jest) will hang after tests complete
process.exit()won't fire until the interval fires or is cleared- In serverless/edge environments the process may not terminate cleanly
.unref() makes the interval "passive" — it runs if the event loop is still active from other work, but it won't prevent the process from exiting.
Sizing the Eviction Window
The cutoff of REFILL_INTERVAL_MS * 2 gives each IP two full refill cycles of inactivity before eviction. A returning client after eviction simply gets a fresh full-token bucket (as if first visit), which is the intended behavior.
For aggressive rate limiting (e.g., blocking brute-force) make the window longer to ensure blocked IPs stay tracked:
const EVICTION_WINDOW_MS = 24 * 60 * 60 * 1000 // 24 hours
setInterval(() => {
const cutoff = Date.now() - EVICTION_WINDOW_MS
for (const [ip, bucket] of buckets) {
if (bucket.lastRefill < cutoff) buckets.delete(ip)
}
}, 60_000).unref()
Production Recommendation
For production rate limiting under real load, prefer Redis-backed solutions (ioredis + sliding window script, or @upstash/ratelimit) that:
- Survive server restarts
- Work across multiple instances
- Have built-in expiry via Redis TTL
Use in-memory Map for:
- Development / single-instance deployments
- Low-traffic internal tooling
- When Redis is unavailable and you control the instance count
Source: daily/2026-05-09.md | 2026-05-09