ehsan.blog
~/blog/how-to-add-in-memory-rate-limiting-to-a-serverless-function — zsh
cat how-to-add-in-memory-rate-limiting-to-a-serverless-function.md

How to add simple in-memory rate limiting to a Vercel serverless function

·4 min read

My contact form posts to a Vercel serverless function. Without any throttling, someone could hammer that endpoint and either run up my email bill (it sends mail via Resend) or just spam my inbox. The simplest possible guard is an in-memory rate limiter: count how many times each IP has hit the endpoint recently, and reject anyone over the limit. Here’s the whole thing, and — just as important — where it falls short.

The idea: a sliding window per IP

I keep a Map from IP address to a list of timestamps. Each request drops old timestamps, adds “now,” and checks whether the count is over the limit:

ts
// api/contact.ts
const WINDOW_MS = 60_000     // 1 minute
const MAX_PER_WINDOW = 5     // 5 requests per IP per minute
const hits = new Map<string, number[]>()

function isRateLimited(ip: string): boolean {
  const now = Date.now()
  const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS)
  recent.push(now)
  hits.set(ip, recent)
  return recent.length > MAX_PER_WINDOW
}

Reading it line by line:

  • hits.get(ip) ?? [] grabs this IP’s timestamps, or an empty array the first time we see them.
  • .filter((t) => now - t < WINDOW_MS) keeps only timestamps from the last 60 seconds, discarding anything older. This is the “sliding window.”
  • recent.push(now) records the current request.
  • If more than 5 timestamps remain, the caller is over the limit.

It’s a sliding window rather than a fixed one: the 60 seconds are always measured backwards from now, so there’s no reset moment a burst can exploit.

Finding the client’s IP

On Vercel the real client IP is in the x-forwarded-for header (the request passes through a proxy, so req.socket.remoteAddress is the proxy, not the user). That header can be a comma-separated list, so I take the first entry:

ts
// api/contact.ts
const ip =
  (req.headers["x-forwarded-for"] as string | undefined)
    ?.split(",")[0]
    ?.trim() ||
  req.socket?.remoteAddress ||
  "unknown"

If nothing is available it falls back to "unknown", which means all unidentifiable requests share one bucket — a reasonable safe default.

Enforcing it

The check runs near the top of the handler, before any real work:

ts
// api/contact.ts
if (isRateLimited(ip)) {
  return res.status(429).json({
    ok: false,
    error: "Too many requests. Please try again shortly.",
  })
}

429 is the standard “Too Many Requests” status code. Returning early means we never touch the email service for a throttled request.

flowchart TD
  A[Request] --> B[Extract IP from x-forwarded-for]
  B --> C{More than 5 hits<br/>in last 60s?}
  C -- Yes --> D[429 Too Many Requests]
  C -- No --> E[Continue to validation + send]

The big caveat: serverless memory is ephemeral

This is honest-to-goodness in the code comments, and it matters: that hits Map lives in one serverless instance’s memory. Serverless functions scale by spinning up multiple instances, and each has its own separate hits Map. They don’t share state. An instance can also be recycled at any time, wiping the Map.

So this limiter only throttles bursts that happen to hit the same warm instance. It’s a cheap, zero-dependency first line of defence against a naive flood — good enough for a personal contact form. If abuse became a real problem, the right fix is a shared store every instance can read, like Redis via Upstash, so the count is global rather than per-instance.

What to remember

  • Track a list of recent timestamps per IP in a Map; filter out old ones each request for a sliding window.
  • Read the client IP from x-forwarded-for on Vercel, not the socket address.
  • Return 429 and bail early when the limit is exceeded.
  • In-memory state is per-instance and can vanish — this only catches bursts to a single warm instance.
  • For serious rate limiting across instances, use a shared store like Redis.

Related reading: rate limiting is one layer of defence in depth — it pairs with a honeypot field to stop form spam and the response headers in hardening a Vercel deploy with security and cache headers.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.