DEV Community

Tech Forge
Tech Forge

Posted on

Rate Limiting Basics: Protecting Your API Without Breaking It

Why Rate Limiting Matters

Every API endpoint has a breaking point. Without limits, a single client can hammer your server with requests, exhausting database connections, CPU, or memory. Rate limiting is the practice of controlling how many requests a client can make in a given time window. It's not just about preventing abuse; it's about keeping your service predictable and fair for everyone.

I've seen production incidents caused by a missing rate limit: a misconfigured cron job that sent thousands of requests per second, taking down the API for all users. A simple 429 Too Many Requests response would have saved the day.

Core Concepts

Before diving into code, let's define the three pillars:

  • Limit: Maximum number of requests allowed in a window.
  • Window: The time period (e.g., 60 seconds, 1 hour).
  • Identifier: Who is being limited? Usually an IP address, API key, or user ID.

Two common algorithms:

  • Fixed Window: Count requests in a fixed time slot (e.g., every minute). Simple but can allow bursts at boundaries.
  • Sliding Window: More precise, uses a rolling time period to avoid boundary spikes.

For most APIs, fixed window is enough. Start simple, then refine if needed.

Implementing a Simple Fixed Window Limiter

Let's build a minimal in-memory limiter in Node.js. This is perfect for single-instance apps or prototyping.

const rateLimit = new Map();

function isRateLimited(identifier, limit, windowMs) {
  const now = Date.now();
  const windowStart = now - windowMs;
  const record = rateLimit.get(identifier);

  if (!record) {
    rateLimit.set(identifier, { count: 1, start: now });
    return false;
  }

  if (record.start < windowStart) {
    // Window expired, reset
    rateLimit.set(identifier, { count: 1, start: now });
    return false;
  }

  record.count += 1;
  if (record.count > limit) {
    return true;
  }
  return false;
}

// Usage in an Express middleware
app.use((req, res, next) => {
  const identifier = req.ip; // or req.apiKey
  if (isRateLimited(identifier, 100, 60000)) {
    res.status(429).json({ error: 'Too many requests' });
  } else {
    next();
  }
});
Enter fullscreen mode Exit fullscreen mode

This works for a single process. But in production, you'll likely have multiple instances behind a load balancer. In-memory maps won't share state. That's where a distributed cache like Redis comes in.

Using Redis for Distributed Rate Limiting

Redis is the go-to for rate limiting because of its atomic operations and built-in expiration. Here's a fixed window implementation using INCR and EXPIRE.

const redis = require('redis');
const client = redis.createClient();

async function isRateLimited(identifier, limit, windowSec) {
  const key = `rate:${identifier}`;
  const count = await client.incr(key);
  if (count === 1) {
    await client.expire(key, windowSec);
  }
  return count > limit;
}
Enter fullscreen mode Exit fullscreen mode

The INCR is atomic, so concurrent requests won't race. The EXPIRE ensures the key disappears after the window. This is clean and fast.

For sliding windows, Redis sorted sets work well, but fixed window is often enough. Don't over-engineer unless you have a real need.

What to Return on Limit Exceeded

The HTTP status code is 429 Too Many Requests. Include a Retry-After header so clients know when to try again. This is part of the HTTP spec and helps clients behave nicely.

res.set('Retry-After', Math.ceil(windowMs / 1000));
res.status(429).json({ error: 'Rate limit exceeded' });
Enter fullscreen mode Exit fullscreen mode

Also consider adding X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers to every response. This transparency helps developers integrate with your API.

Choosing the Right Identifier

IP addresses are easy, but they can be unreliable. Multiple users behind a NAT share the same IP, and a single user can rotate IPs. If you have authentication, use the user ID or API key. That's more accurate.

For public endpoints without auth, IP is the only option. In that case, use a combination: IP plus a hash of the User-Agent or a short-lived token.

Common Pitfalls

  • Not cleaning up: In-memory maps need periodic cleanup to avoid memory leaks. Use a TTL or a scheduled job.
  • Limiting too aggressively: Set limits high enough for legitimate usage. Monitor real traffic and adjust.
  • Forgetting about retries: Clients may retry on 429, so make sure your limiter is consistent across retries.
  • Ignoring latency: Redis calls add latency. Keep the logic minimal, or use a local cache with periodic sync.

Going Further

If you're using a framework, check for built-in support. Express has express-rate-limit, and many API gateways (like Nginx or Kong) have rate limiting modules. For microservices, consider a sidecar pattern or a central rate limiting service.

Rate limiting is not a silver bullet. It's a guardrail. Combine it with authentication, input validation, and monitoring to keep your API healthy.

Start with a simple fixed window, measure, and iterate. Your future self will thank you when the traffic spike hits.

Top comments (0)