Application Development

How to Add Rate Limiting to Your App: Step-by-Step

J
James Eriksson
··11 min read
Step-by-step guide to implementing rate limiting in Node.js and Python. Prevent runaway costs, monitor before the bill hits, and avoid common pitfalls.
TL;DR
  • Without rate limiting, a retry loop or power user can spiral into surprise bills before you notice.
  • Implement rate limiting at the application level with express-rate-limit or Python limits, backed by Redis for distributed systems.
  • Monitor rate-limit hit rates and cost forecasts in real time; discover problems through alerts, not invoices.
  • Avoid local per-process counters behind load balancers; use Redis or a gateway service instead.
  • Use Token Bucket algorithm for fairness and burst control; test with load tools like k6 before production.

Rate limiting prevents the bill that surprises you. A retry loop, a rerender bug, or one power user can spiral into unexpected API costs before you notice. This guide walks you through three implementation strategies, working code examples, and how to catch rate-limiting bugs before they hit your wallet.

Why Rate Limiting Isn't Optional

Rate limiting stops runaway costs by capping how many requests a user, service, or IP can make in a time window. Without it, a single misconfiguration scales into a financial disaster.

The danger is real. A background job that retries on failure can cascade: fail once, retry after 10 seconds, fail again, retry after 20 seconds, retry after 40 seconds. By the time you wake up and check logs, you've generated thousands of duplicate API calls. Each one costs money.

Power users are another trap. One customer with a misconfigured script, an aggressive polling loop, or a browser tab left open can consume your entire monthly quota in hours. The third danger is bot traffic. Without limits, an attacker (or accident) can flood your endpoints with requests fast enough to make your service unavailable.

Rate limiting fixes all three. It throttles requests before they become bills, alerts you to abuse patterns, and makes your service predictable. Reddit and startup communities stress this over and over: the limit that saves you is usually boring and happens before you notice. Install it now.

The Three Ways to Add Rate Limiting

You can enforce rate limits at three layers: in your application code, at the API gateway, or via a third-party service. Each has trade-offs.

Application-level rate limiting lives in your middleware. You write code (or add a library like express-rate-limit for Node or limits for Python) that tracks request counts per user, IP, or API key. When a threshold is crossed, return a 429 (Too Many Requests) status. This approach is free, gives you full control, and works for single-server apps. The downside: it only works if all traffic flows through the same process. Behind a load balancer with five instances, each instance tracks limits independently, so a user can make five times as many requests before hitting your per-instance cap. Fix this by backing the counter with Redis (shared database), which adds complexity and a new dependency.

API gateway rate limiting runs outside your app. Services like Cloudflare WAF, AWS API Gateway, Kong, or similar apply limits before requests hit your backend. The benefit: one rule protects all instances automatically. No load balancer problems. The cost: setup and operational overhead. You're adding another component to understand and troubleshoot. For small teams, this can feel like overkill.

Managed hosting with built-in rate limiting outsources the problem entirely. Opsily, Heroku, and similar services bake rate limiting into their platform. You set a limit, and the platform enforces it transparently. No code to write, no Redis to manage. The tradeoff is cost: you pay for the convenience, and the limit is usually tied to the plan (more expensive plans allow higher limits).

For most apps, start with application-level rate limiting backed by Redis. It's the balance between simplicity and power. Use a gateway if you need distributed rate limiting and have the operational skills. Use managed hosting if operations aren't your focus.

Core Concepts: Identifiers, Algorithms, and Timing

Rate limiting requires three decisions: what to count, how to count, and when to reset.

Partition key is what you count by. Choices include IP address (broad but catches bot traffic), user ID (fair per-user limits), API key (good for B2B), or endpoint (different limits per route). Pick the granularity that matches your problem. If power users are the issue, use user ID. If you're worried about DDoS, use IP.

Algorithm is how you track counts. Three dominate:

  1. Fixed Window: Count requests in each minute (or hour). At minute 1:00-1:59, allow 100 requests. At 2:00, reset to zero. Fast to implement, cheap to run. Downside: burst abuse at window boundaries. A user can make 100 requests at 1:59 and 100 more at 2:00, totaling 200 in two seconds.

  2. Sliding Window: Track each request with a timestamp. Remove requests older than the window. If the window is 1 minute and you've made 50 requests in the last 60 seconds, a new request only counts if fewer than 100 requests happened in the last 60 seconds. More accurate, prevents bursts, but requires more memory to track timestamps.

  3. Token Bucket: Imagine a bucket that refills tokens at a rate (e.g., 10 tokens per second). Each request costs one token. If the bucket is empty, reject. Allows controlled bursts (the bucket can hold up to the max) while keeping per-second throughput fair. Most credit-card-processing APIs use this.

For most applications, Token Bucket is best. It's fair, prevents both bursts and starvation, and scales well.

Timing is how fast you reset. Common windows are per-second (for strict controls), per-minute (standard), per-hour (loose), per-day (for strict quotas like API keys). Start conservative: 100 requests per minute is reasonable for most apps. Monitor usage and loosen if needed.

Implementing Rate Limiting in Node.js with express-rate-limit

For Node.js, express-rate-limit is the standard library. It's maintained, widely used (3,273 GitHub stars), and integrates with Express and Fastify.

Here's a basic setup:

const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const redis = require('redis');

const client = redis.createClient({
  host: 'localhost',
  port: 6379,
});

const limiter = rateLimit({
  store: new RedisStore({
    client: client,
    prefix: 'rl:', // Redis key prefix
  }),
  windowMs: 1 * 60 * 1000, // 1 minute
  max: 100, // 100 requests per minute
  message: 'Too many requests, please try again later.',
  standardHeaders: true, // Return rate-limit info in RateLimit-* headers
  legacyHeaders: false,
});

app.use('/api/', limiter);

This applies the same 100-req/min limit to all users. If you need per-user limits:

const limiter = rateLimit({
  store: new RedisStore({ client }),
  windowMs: 60 * 1000,
  max: 100,
  keyGenerator: (req) => req.user ? req.user.id : req.ip, // Use user ID if authenticated
});

Now each user gets 100 requests per minute independently. Unauthenticated requests are limited by IP.

To test your rate limit, make 101 requests rapidly to your endpoint. Replace YOUR_APP_URL below with your application's address:

for i in {1..101}; do curl YOUR_APP_URL/api/test; done

On the 101st request, you'll see a 429 response. Check the response headers: RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset tell the client how many requests remain and when the window resets.

Redis setup is minimal. Install Redis locally or use a managed service. Most platforms (Heroku, AWS, DigitalOcean) offer Redis as an add-on. Cost is low (USD 5-20/month for small deployments).

Implementing Rate Limiting in Python with limits and FastAPI

In Python, the limits library (sometimes ratelimit) is standard. Combined with slowapi (for FastAPI) or Flask-Limiter, it's simple and effective.

Here's FastAPI with slowapi:

from slowapi import Limiter
from slowapi.util import get_remote_address
from fastapi import FastAPI

limiter = Limiter(key_func=get_remote_address)
app = FastAPI()

@app.get("/api/items")
@limiter.limit("100/minute")
async def get_items(request: Request):
    return {"items": []}

By default, slowapi tracks by client IP. For per-user limits, customize the key function:

def get_user_id(request: Request):
    # Extract user ID from JWT, session, or API key
    user_id = request.headers.get("X-User-ID")
    return user_id or get_remote_address(request)

limiter = Limiter(key_func=get_user_id)

With Redis backend (recommended for distributed systems):

from limits.strategies import MovingWindowRateLimiter
from limits import parse
import redis

storage = redis.from_url('redis://localhost:6379')
limiter = MovingWindowRateLimiter(storage)

# In your route
if not limiter.test(f"user-{user_id}", "100/minute"):
    raise HTTPException(status_code=429)

Python's limits library uses sliding windows by default (more accurate than fixed windows). Monitor and adjust the limit string ("100/minute") based on actual usage.

Setting Up Monitoring and Alerts Before the Bill Hits

Rate limiting only works if you know when it's triggered. Too many teams discover a problem through the invoice, not through alerts.

Set up real-time dashboards. If you're using Redis, track these metrics:

  • Rate-limit hit rate: How often are requests hitting the limit? If it's zero, either your limit is too loose or traffic is normal. If it's suddenly 50%, something changed (bot attack, code bug, new feature spam).
  • Cost forecast: Estimate daily cost based on current request volume. Alert if it exceeds 10% above baseline.
  • Top consumers: Which users, IPs, or endpoints are consuming quota fastest? Is it bot traffic or a legitimate customer?

With Datadog, CloudWatch, or similar platforms:


# Log every rate-limit hit
redis_client.incr(f"metrics:rate-limit-hits:{hour}")
redis_client.incr(f"metrics:api-calls:{hour}")

# Calculate and alert
rate_limit_hits = redis_client.get(f"metrics:rate-limit-hits:{hour}")
api_calls = redis_client.get(f"metrics:api-calls:{hour}")
hit_ratio = rate_limit_hits / api_calls

if hit_ratio > 0.05:  # Alert if more than 5% of requests hit the limit
    send_alert(f"High rate-limit hit ratio: {hit_ratio}")

This catches problems within minutes, not days. When the alert fires, investigate: Is it a DDoS? A retry loop? A customer's new feature generating unexpected load? Quick answers save money.

Common Pitfalls and How to Avoid Them

Local per-process counters behind load balancers. If each server instance maintains its own in-memory counter, a user can slip between instances. Use Redis or memcached. Cost: minimal (managed Redis is cheap). Benefit: stops the bleeding.

Off-by-one errors in time windows. A window starting at 1:00:00 and ending at 1:01:00 is 60 seconds, not 61. Boundaries matter. Most libraries handle this correctly, but test your implementation. Send 101 requests in 10 seconds and verify that request 101 is rejected.

Forgetting to reset windows. If your window implementation has a bug and never resets, users get stuck until manual intervention. Use a library (like express-rate-limit) rather than hand-coding. Tested code is safer.

Over-aggressive burst limits. Token Bucket allows bursts, but if your bucket is too large, a single client can still consume most of your quota. If the per-minute limit is 100 but the bucket holds 500, one burst consumes 5 minutes' worth. Set the bucket size (usually called max or burst) to a small multiple of the per-second rate (e.g., bucket size = 10 seconds' worth).

Not handling 429 responses on the client. Your frontend and mobile apps must gracefully handle 429 errors. Implement exponential backoff: first retry after 2 seconds, then 4, then 8. Cap at 60 seconds. This prevents retry loops from making the problem worse.

Mixing rate limits across layers. If you have both application-level limits and API gateway limits, make sure they're coordinated. A 100 req/min app limit plus a 50 req/min gateway limit is confusing. Users will hit the gateway limit and see a different error than your app. Use one strong limit or clearly document both.

Frequently Asked Questions

What HTTP status code should I return when the limit is hit?

Use 429 (Too Many Requests). This is the standard. Include Retry-After header to tell clients when to retry. Example: Retry-After: 60 means retry in 60 seconds.

How do I white-list certain IPs or users?

Most rate-limit libraries support skip conditions. In express-rate-limit:

const limiter = rateLimit({
  skip: (req) => req.ip === '1.2.3.4' || req.user?.isAdmin,
});

Should I rate-limit authenticated users differently than anonymous ones?

Yes. Anonymous users can spam easily, so limit them strictly (e.g., 10 req/min). Authenticated users have skin in the game (account reputation) and can handle looser limits (e.g., 1000 req/min). If an authenticated user abuses limits, flag the account.

What happens to requests that exceed the limit?

You can queue them, throttle them (delay before processing), or reject them with 429. Queuing is friendliest but needs a task queue (like Bull/RabbitMQ). Throttling is fair but delays responses. Rejection is fastest but frustrates users. Choose based on the use case.

How do I test rate limiting before production?

Load testing tools like k6 or Apache JMeter can hammer your endpoint with concurrent requests. Here's an example with k6 (replace YOUR_APP_URL with your application address):

import http from 'k6/http';
import { check } from 'k6';

export default function () {
  const res = http.get('YOUR_APP_URL/api/test');
  check(res, {
    '429 when limit exceeded': (r) => r.status === 429,
  });
}

Run with k6 run script.js --vus 10 --duration 30s to simulate 10 concurrent users for 30 seconds. Watch for 429 responses.

Can I use rate limiting to prevent DDoS attacks?

Partially. Rate limiting on a per-IP basis slows down attackers but isn't a full DDoS defense. Attackers use botnets with rotating IPs. For serious DDoS protection, use a WAF (Cloudflare, AWS Shield) in front of your app. They have more sophisticated traffic analysis than simple rate limits.

What's the difference between rate limiting and throttling?

Rate limiting rejects excess requests (429). Throttling delays excess requests so they complete slower. Throttling is kinder but can use more resources. Hybrid: throttle for 5 seconds, then reject if the client doesn't back off.

The Bottom Line

Rate limiting is not a luxury. It's the difference between predictable costs and surprise bills. A retry loop or a power user can multiply your API costs 10x in hours. Rate limiting catches both before they cost money.

Start with application-level rate limiting using express-rate-limit (Node) or slowapi (Python), backed by Redis. Monitor hit rates and cost forecasts. Test load with k6 or JMeter to verify your limits don't break the user experience. When rate limits fire, investigate immediately.

If managing Redis and custom logic feels risky or you want predictable infrastructure costs, managed hosting solutions like Opsily offer built-in rate limiting as part of the platform.

Deploy Without Rate-Limit Headaches
Opsily's managed hosting includes built-in rate limiting and predictable flat pricing—no surprise bills.
Get Started Free

Ready to self-host your own apps?

One server. Multiple apps. No per-app fees.

Get started →