Skip to content

perf(rate-limit): evict expired keys so the Map stays bounded - #8

Merged
aymandakirgh merged 1 commit into
mainfrom
fix/rate-limiter-evict-expired-keys
Jun 18, 2026
Merged

perf(rate-limit): evict expired keys so the Map stays bounded#8
aymandakirgh merged 1 commit into
mainfrom
fix/rate-limiter-evict-expired-keys

Conversation

@aymandakirgh

Copy link
Copy Markdown
Owner

Why

InMemoryRateLimiter (lib/rate-limit.ts) keys on client IP but never removes a key once its sliding window has fully elapsed. check() / remaining() always store the pruned timestamp list (even when it is empty) and there is no sweep. On a long-lived warm Vercel function instance this is a slow but real memory leak: one permanent Map entry per distinct IP that ever POSTed /api/lead, retained for the instance's whole lifetime even after that IP goes quiet.

What

  • Add a private sweep() that deletes every key whose newest timestamp has aged out of the window, called opportunistically at the top of check(), so the Map stays bounded to currently-active keys (O(live keys), not O(all keys ever seen)). Implemented with Map.forEach + a collected delete list to stay within the project's tsconfig target (no --downlevelIteration needed).
  • In remaining(), delete the key when its pruned list is empty instead of leaving an empty-array entry behind.
  • Expose a read-only size getter so the bound is observable/assertable.
  • Add regression tests: a key is evicted once its window elapses; size collapses toward 0 as keys age out; a stream of 50 unique IPs never grows the Map past 1 entry; remaining() leaves no stale entry; and the limit is still enforced for active keys (no behavior regression).

Verification

  • npm run typecheck — pass
  • npm run lint — pass (no warnings/errors)
  • npm test — pass (235 tests, +5 new rate-limiter guards)
  • npm run build — pass
  • Confirmed the 4 new leak-guard tests fail against the old implementation (no eviction / no size) and pass after the fix; the no-regression test passes on both.

Co-authored-by: mattia-mamini-gh 281593356+mattia-mamini-gh@users.noreply.github.com

WHY: InMemoryRateLimiter keyed on client IP but never removed a key once
its sliding window had fully elapsed — check()/remaining() always stored
the pruned timestamp list (even when empty) and there was no sweep. On a
long-lived warm Vercel instance this is a slow but real memory leak: one
permanent Map entry for every distinct IP that ever POSTed /api/lead,
retained for the instance's whole lifetime even after the IP went quiet.

WHAT:
- Add a private sweep() that deletes every key whose newest timestamp has
  aged out of the window; call it opportunistically at the top of check()
  so the Map is bounded to currently-active keys (O(live keys)). Uses
  Map.forEach + a collected delete list to stay within the project's
  tsconfig target (no downlevel iteration).
- In remaining(), delete the key when its pruned list is empty instead of
  leaving an empty-array entry behind.
- Expose a read-only `size` getter so the bound can be asserted in tests.
- Add regression tests: a key is evicted once its window elapses, size
  returns toward 0 as keys age out, a stream of 50 unique IPs never grows
  the Map past 1 entry, remaining() leaves no stale entry, and the limit
  is still enforced for active keys (no behavior regression).

Co-authored-by: mattia-mamini-gh <281593356+mattia-mamini-gh@users.noreply.github.com>
@aymandakirgh
aymandakirgh merged commit 53b54ff into main Jun 18, 2026
1 check passed
@aymandakirgh
aymandakirgh deleted the fix/rate-limiter-evict-expired-keys branch June 18, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant