Skip to content
Nafiz Iqbal
← Engineering Log

, 2 min read. Backend, Reliability

Rate limits behind carrier-grade NAT

Why per-IP rate limiting punishes innocent mobile users in markets like Bangladesh, and the per-user approach ThoughtExpense uses instead.

Per-IP rate limiting is the default almost everywhere: one bucket per client address, usually enforced at the reverse proxy. It is cheap, it needs no application code, and for many audiences it works. For a mobile app whose users are largely on Bangladeshi carrier networks, it is the wrong unit.

The problem

Mobile carriers commonly use carrier-grade NAT (CGNAT): many subscribers share a single public IPv4 address. To the server, hundreds of unrelated people can look like one very busy client. A per-IP limit sized for one person throttles all of them; a limit sized for all of them protects nothing.

The fix

  • Authenticated routes are limited per user ID, not per address. Each person gets their own budget regardless of which network they are on.
  • Expensive routes, such as AI parsing, get their own tighter per-user budgets because they cost money per call.
  • The limiter fails open. If the cache backing the counters is unavailable, the request proceeds and the failure is logged, rather than turning a cache outage into a full outage.
export function limitByUser(opts: { key: string; limit: number; windowSec: number }) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const userId = req.auth?.userId;
    if (!userId) return next(); // unauthenticated traffic is handled separately

    try {
      const bucket = `rl:${opts.key}:${userId}`;
      const count = await cache.incr(bucket);
      if (count === 1) await cache.expire(bucket, opts.windowSec);
      if (count > opts.limit) return res.status(429).json({ error: "rate_limited" });
    } catch (err) {
      log.warn({ err }, "rate limiter unavailable, failing open");
    }
    next();
  };
}
Simplified illustration of the approach, not production code.

Trade-offs

Limits now live in application code rather than in the proxy, which is more code to own. Traffic before sign-in still needs protection, and that part cannot be keyed by user. Failing open also means a cache outage briefly removes the limits; for this product, that was a better failure than refusing every request.

Verification

The behaviours worth testing are concrete: two users on the same address get independent budgets; one user exceeding a budget gets a 429 while others do not; and a simulated cache failure lets requests through and emits a warning instead of an error response.