obsidian/wiki/concepts/map-ratelimiter-memory-leak.md
2026-05-10 22:37:43 +01:00

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