---
name: map-ratelimiter-memory-leak
description: In-memory Map rate limiters grow unbounded in long-running Node.js servers — need setInterval eviction with .unref()
type: 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:

```ts
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()`

```ts
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:

```ts
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
