DEV Community

Cover image for Building a Simple, Effective Rate Limiter for Next.js API Routes (No Redis Required)
Aon infotech
Aon infotech

Posted on

Building a Simple, Effective Rate Limiter for Next.js API Routes (No Redis Required)

Most tutorials on API rate limiting reach straight for Redis. That's the right call at scale, but for a huge number of small-to-medium projects — internal tools, side projects, low-traffic public APIs — Redis is an unnecessary dependency you now have to provision, monitor, and pay for. This post covers a simple in-memory rate limiter for Next.js API routes that handles the common case well, plus where it breaks down and when you actually need the heavier solution.

Why in-memory rate limiting works for many cases

The objection to in-memory rate limiting is always the same: "it doesn't work across multiple server instances." That's true, and it matters a lot if you're running horizontally scaled serverless functions with no shared state. But a large share of Next.js deployments — a single long-running Node server, a small self-hosted instance, or even serverless deployments where you're rate-limiting per-function rather than globally — don't need cross-instance coordination for this to be useful. It stops the most common abuse pattern: a single client hammering an endpoint.

The core implementation

// lib/rate-limit.js
const requestLog = new Map();

export function rateLimit({ interval, uniqueTokenLimit }) {
  return {
    check: (limit, token) => {
      const now = Date.now();
      const tokenLog = requestLog.get(token) || [];

      // Remove timestamps outside the current window
      const validTimestamps = tokenLog.filter(
        (timestamp) => now - timestamp < interval
      );

      if (validTimestamps.length >= limit) {
        return { success: false, remaining: 0 };
      }

      validTimestamps.push(now);
      requestLog.set(token, validTimestamps);

      // Prevent unbounded memory growth
      if (requestLog.size > uniqueTokenLimit) {
        const oldestKey = requestLog.keys().next().value;
        requestLog.delete(oldestKey);
      }

      return {
        success: true,
        remaining: limit - validTimestamps.length,
      };
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

This is a sliding-window implementation: rather than resetting counts at fixed intervals (which allows bursts right at the boundary), it tracks actual request timestamps and only counts ones within the trailing window.

Using it in an API route

// pages/api/generate.js
import { rateLimit } from '@/lib/rate-limit';

const limiter = rateLimit({
  interval: 60 * 1000, // 1 minute window
  uniqueTokenLimit: 500, // max distinct clients tracked
});

export default async function handler(req, res) {
  const identifier = getClientIdentifier(req); // IP, session, or API key

  const { success, remaining } = limiter.check(10, identifier);

  if (!success) {
    res.setHeader('Retry-After', '60');
    return res.status(429).json({
      error: 'Too many requests. Please try again shortly.',
    });
  }

  res.setHeader('X-RateLimit-Remaining', remaining.toString());

  // Your actual handler logic here
  const result = await processRequest(req.body);
  res.status(200).json(result);
}
Enter fullscreen mode Exit fullscreen mode

For the App Router, the same logic drops into a Route Handler with the standard Request/Response API instead of req/res — the rate limiter itself doesn't change.

Choosing a client identifier

This is where a lot of naive implementations go wrong. IP address alone is a reasonable default but has real limitations:

  • Shared IPs: Users behind the same corporate NAT or mobile carrier gateway share an IP, so one heavy user can throttle everyone else on that connection.
  • IP spoofing at the header level: If you're behind a proxy or CDN, make sure you're reading x-forwarded-for correctly rather than the connection IP, which will just be your proxy's address for every request.
  • Session or API key identifiers are more precise when available — prefer them over IP if your endpoint has any form of authentication or session tracking, even a lightweight one.
function getClientIdentifier(req) {
  // Prefer authenticated identifier if present
  if (req.headers['x-api-key']) return req.headers['x-api-key'];

  // Fall back to IP, correctly reading forwarded headers behind a proxy
  const forwarded = req.headers['x-forwarded-for'];
  return forwarded ? forwarded.split(',')[0].trim() : req.socket.remoteAddress;
}
Enter fullscreen mode Exit fullscreen mode

Where in-memory rate limiting breaks down

Be honest with yourself about these limits before shipping this to production:

Multiple instances, no shared state. If you're running several serverless function instances or multiple pods behind a load balancer, each instance has its own independent Map. A client could get 10 requests through each of 5 instances before any single one blocks them — effectively multiplying your intended limit by your instance count.

Memory resets on redeploy or cold start. Serverless functions in particular may spin up fresh instances frequently, silently resetting everyone's rate limit state. This is fine for casual abuse deterrence, less fine if you're relying on it for strict quota enforcement.

No persistence across restarts. A server restart clears all tracked state. Again, acceptable for soft protection, not for hard guarantees.

When to move to Redis (or Upstash)

The signal to switch is simple: once you're running more than one server instance handling the same endpoint, or once rate limiting is a genuine security/business requirement rather than a "stop obvious abuse" measure, move to a shared store. Upstash's Redis-compatible REST API pairs well with serverless Next.js deployments specifically because it doesn't require a persistent TCP connection, which serverless functions handle poorly. The rate-limiting logic itself — sliding window, per-client tracking — stays conceptually the same; only the storage layer changes from a Map to a network call.

Summary

In-memory rate limiting is a legitimate, useful tool for a specific and common set of circumstances: single-instance deployments, internal tools, and public APIs where you want to deter obvious abuse without provisioning additional infrastructure. It is not a substitute for a distributed rate limiter once you're running multiple instances or need hard guarantees. Know which category your project falls into before you pick a solution — most projects starting out are in the first category and can safely defer the Redis conversation until traffic actually demands it.

If you're building something with heavier image-generation workloads that genuinely benefit from request throttling — Pixova's AI image generator is one example of a use case where per-client throttling matters — the same sliding-window logic applies, just tuned to your specific cost and abuse profile.

Top comments (0)