In this lab you'll design Tiffin's rate limits from scratch. There's no single right answer. What matters is that every number traces back to real traffic, and that no hungry customer at 8pm ever sees "too many requests". Decide each step before opening the reasoning.
The brief
Tiffin is two years old and doing well in its city. The setup:
- About 3,600 requests a second from real customers at the dinner peak, on 8 stateless app servers behind the managed load balancer from Module 4, with nginx as a reverse proxy in front of each app server.
- Redis is already running for caching (Module 3).
- Customers log in with a phone number and a 4-digit OTP sent by SMS. Each SMS costs ₹0.25.
- Today there are no rate limits at all.
Last month went badly. Four incidents landed on your desk:
- The scraper. Menu requests at 2,500 a second for two hours, logged out, from 40 IP addresses that all belong to a cloud provider. Dinner-time latency doubled.
- The coupon guesser. One account tried 30,000 coupon codes in a night and found three private influencer codes. They were shared on a deals forum and cost ₹1.2 lakh before anyone noticed.
- SMS pumping. 200,000 OTPs sent in one night to numbers the attacker profits from. Bill: ₹50,000.
- Our own bug. App version 5.3 retried
POST /ordersin a tight loop whenever the request timed out. Some customers got the same order three or four times.
The head of engineering's brief: "Stop all four. Don't block a single real customer at dinner. And cap how much an SMS attack can ever cost us."
Step 1: Measure normal
You can't set a limit without knowing what normal looks like. You pull a week of logs. Here's what real customers do, counted per user (or per IP for logged-out traffic):
| Endpoint | Typical | Busiest real 0.1% | Seen from abusers |
|---|---|---|---|
| Browsing (menus, search, restaurants), logged in | 6 per minute | 60 per minute | - |
| Browsing, logged out, per IP | 10 per minute | 1,800 per minute (a carrier NAT address) | 3,750 per minute per cloud IP |
| Apply coupon, wrong codes | 0 | 4 per hour | 30,000 per night |
| Send OTP, per phone number | 1 per login | 3 per 10 minutes | Thousands of numbers, 1 to 2 each |
| Send OTP, total | 2,000 per hour at peak | - | 25,000 per hour |
| Place order | 1 per evening | 3 per 10 minutes (payment failed, tried again) | - |
What jumps out?
Before setting any number, look at the table. Which incident can a simple per-IP limit stop, and which can't it?
Show one answer
The scraper sends 3,750 requests a minute per IP. But a real carrier NAT address already sends 1,800 a minute at peak, from many customers sharing it. Any per-IP limit low enough to really hurt the scraper is uncomfortably close to blocking a whole neighbourhood of real customers. Per-IP alone can't solve incident 1. You need something that tells the scraper's IPs apart: they belong to a cloud provider, and real customers don't browse menus from a data centre.
SMS pumping uses thousands of phone numbers, each used only once or twice. A per-phone limit doesn't touch it. It needs a total limit on OTP sends.
The coupon guesser is easy: 30,000 wrong codes against a real maximum of 4 an hour.
Step 2: Pick the key for each limit
Who gets counted?
For each of the four incidents, decide what key the limit should count by.
Show one answer
- Scraper: IP address, but treated differently by where the IP comes from. Cloud provider IP ranges (the lists are public) get a strict limit. Mobile and home IPs get a generous seatbelt. Logged-in browsing is counted per user ID.
- Coupon guesser: user ID, counting only wrong codes. And coupons require login, so the guesser can't hide behind fresh IPs.
- SMS pumping: three keys at once. Per phone number (stops hammering one number), per IP and per device (stops one machine sending many), and a global total per hour (stops a spread-out attack).
- Our own bug: user ID on
POST /orders. But a limit only bounds the damage; see Step 3.
No single key works everywhere. IP is weak (carrier NAT) but it's all you have before login. User ID is best once you have it. Phone number protects the target. A global total protects your wallet.
Step 3: Set the numbers
Write the limits
Using the table from Step 1, write a limit for each key. Show how each compares to the busiest real user.
Show one answer
| Limit | Key | Number | Headroom over real users |
|---|---|---|---|
| General API, logged in | User ID | Bucket of 50, refills 5 per second (300 per minute) | 5x the busiest 0.1% |
| Browsing, logged out | IP (mobile/home) | 6,000 per minute | 3x the busiest NAT address |
| Browsing, logged out | IP (cloud ranges) | 60 per minute | Real customers don't come from here |
| Wrong coupon codes | User ID | 10 per hour | 2.5x the busiest real user |
| Send OTP | Phone number | 3 per 10 minutes | Matches the busiest real user |
| Send OTP | IP | 20 per hour | Allows a shared NAT IP many logins |
| Send OTP | Global | Alarm at 6,000 per hour, hard stop at 10,000 per hour | 3x and 5x the normal peak |
| Wrong OTP attempts | Per code | 5, then the code is dead | Real users mistype once or twice |
| Place order | User ID | 10 per 10 minutes | 3x the busiest real user |
Check each one against its incident.
Scraper. Each cloud IP now gets 60 menus a minute instead of 3,750. Across 40 IPs that's 2,400 a minute, or 40 a second, down from 2,500 a second. A determined scraper will rent more IPs or switch to mobile proxies, so this is a speed bump, not a wall. The real protection for capacity is making scraping cheap: menus are cached (Module 3), so even scraped requests barely touch the database.
Coupon guesser. 10 wrong codes an hour means 30,000 guesses would take 125 days on one account. Each new account needs a new phone number and an OTP, which is itself limited.
SMS pumping. The hard stop is 10,000 OTPs an hour. Even if nobody answers the page for the whole 8-hour night of last month's attack, that's at most 80,000 × ₹0.25 = ₹20,000, down from ₹50,000. In practice the alarm at 6,000 an hour pages someone within minutes, and the attack is blocked far below the hard stop. The hard stop has a price: once it trips, real customers can't get an OTP either until the hour rolls over. That's why it sits well above the normal peak, and the alarm fires long before it. (Rate limits aren't the only defence here: blocking premium-rate number ranges and countries Tiffin doesn't serve helps just as much.)
Our own bug. 10 orders in 10 minutes still lets a broken app place several duplicates. A rate limit can't tell "the same order again" from "a new order". The real fix is an idempotency key, so a retried request with the same key returns the first order instead of creating a new one. That's Module 12. The rate limit is a backstop that stops a runaway loop from placing hundreds.
Step 4: Choose the algorithm and where each limit runs
Place each limit
For each limit, pick an algorithm (fixed window, sliding log, sliding counter, token bucket) and a layer (nginx, app middleware, inside the feature).
Show one answer
| Limit | Algorithm | Where |
|---|---|---|
| Logged-out IP seatbelts | Leaky bucket (limit_req) |
nginx |
| General logged-in API | Token bucket (the app is bursty) | App middleware, Redis Lua script |
| Wrong coupon codes | Sliding log (tiny numbers, must be exact) | Coupon code, Redis |
| OTP per phone, per IP | Sliding log | OTP code, Redis |
| OTP global | Fixed window per hour | OTP code, Redis |
| Wrong OTP attempts | A counter stored with the code | OTP code, Redis |
| Place order | Sliding window counter | Order code, Redis |
The reasoning:
- The IP seatbelt goes at nginx because it needs no business knowledge and it's cheapest to reject a flood before it reaches an app server. nginx can map cloud IP ranges to the strict zone. Two details come from nginx sitting behind the load balancer. Every request arrives from the load balancer's address, so nginx must take the client's IP from the
X-Forwarded-Forheader with itsreal_ipmodule, trusting that header only from the load balancer's (and CDN's) addresses; otherwise every phone shares one bucket, or a client can forge its IP. And each nginx instance counts on its own while the load balancer spreads one IP's requests over all eight, so set each instance to an eighth of the fleet-wide number: 750 a minute for mobile and home IPs, about 8 for cloud ranges. That's rough, which is fine for a seatbelt. - The per-user bucket goes in middleware because only the app knows who's logged in. It runs before any handler, so a rejected request costs one Redis call and nothing else.
- Feature limits live inside the features because only the coupon code knows a coupon was wrong, and only the OTP code knows which phone number is being targeted.
Here's one OTP request passing every limit on its way to the SMS provider:
%% caption: A logged-out OTP request. Cheap checks first, the money-saving global cap last. sequenceDiagram autonumber participant P as Phone participant N as nginx participant A as App server participant R as Redis participant S as SMS provider P->>N: send OTP to my number Note over N: IP seatbelt, counted locally N->>A: forward A->>R: per-phone and per-IP logs R-->>A: under both limits A->>R: global hourly count R-->>A: under the cap A->>S: send the SMS A-->>P: code sent
The extra Redis load is roughly 5,000 calls a second at about 0.5 ms each. One Redis handles that easily. Limits get their own Redis instance so a cache flush or cache eviction can't reset everyone's counters.
Step 5: What happens over the limit, and when Redis breaks
Responses and failure modes
Decide two things for each kind of limit: what the customer sees when they hit it, and what the server does if Redis can't be reached.
Show one answer
Responses. Every limit returns 429 with Retry-After. The app shows a human message per feature:
- General API: a short wait and a quiet automatic retry after
Retry-After. Real customers should never see this. - Coupon: "Too many wrong codes. Try again in 40 minutes." The order can still be placed without a coupon.
- OTP: a countdown, "You can request a new code in 4:12".
- Orders: "Your order is being placed. Please wait." This is mostly for broken clients.
Failures.
| Limit | If Redis is down |
|---|---|
| General API | Fail open, with a local in-memory bucket per server as fallback |
| Coupon | Fail closed: coupons temporarily unavailable, ordering still works |
| OTP send | Fail closed to a strict local limit (1 per phone per server per 10 minutes) |
| Orders | Fail open: blocking dinner orders costs more than a few duplicates |
All Redis calls have a 10 ms timeout, so a slow Redis can't slow every request.
The pattern: fail open where the limit protects capacity and blocking would cost real orders. Fail closed where the limit protects money or security and there's a cheap way for the customer to carry on.
App version 5.4 is shipped before the limits go live, so Tiffin's own app honours Retry-After and backs off instead of hammering a 429.
Step 6: Roll it out
Turning it on safely
It's Monday. How do you turn these limits on without risking Saturday dinner?
Show one answer
- Shadow mode for a week. Every limit counts and logs "would have blocked", but blocks nothing.
- Check who would have been blocked. For each limit, list the top keys. Any user ID with a history of completed, paid orders is almost certainly a real customer. If even a few show up, raise that limit.
- Turn limits on one at a time, on a weekday afternoon, starting with the most obviously abusive (OTP global cap, cloud IPs, coupons). Leave the general per-user bucket for last.
- Dashboards and alarms. 429s per endpoint per minute, top limited keys, OTP sends per hour. An alarm if 429s for logged-in users go above 0.1% of requests, which would mean real customers are being hit.
- A kill switch. Each limit can be switched back to shadow mode in seconds, without a deploy.
The final design
%% caption: Tiffin's rate limits. Loose and cheap at the edge, per user in the app, strict inside the features that cost money or invite attacks.
flowchart TD
P([Phone]) --> CDN["CDN<br/>cached menus"]
CDN --> LB["Load balancer<br/>managed, layer 7"]
LB --> NG["nginx on each app server<br/>IP seatbelt, strict for cloud IPs"]:::hot
NG --> AS["App servers<br/>per-user token bucket"]
AS --> F["Feature code<br/>OTP, coupon, order"]
AS --> R[("Redis, limits only<br/>shared counters, Lua scripts")]:::hot
F -- "strict feature limits" --> R
F -- "OTP: global hourly cap first" --> SMS[SMS provider]Read it back against the brief. The scraper is slowed at nginx and mostly hits cache. The coupon guesser gets 10 tries an hour. An SMS attack has a known worst-case bill and pages someone early. A buggy app stops at 10 orders. And every limit sits at or above the busiest real customer and was proven in shadow mode first.
Build it on AWS
Tiffin runs in Mumbai (ap-south-1): phones talk to an Application Load Balancer, the app runs on ECS with Fargate, menus are served from ElastiCache for Valkey and an in-process cache (Module 3), and CloudFront serves only dish photos. On AWS the lab's nginx disappears, and its job moves to AWS WAF. The business limits stay in the app, exactly as designed. Prices are us-east-1 list prices as of late 2026, fine for comparing options; Mumbai's differ, so check the AWS pricing calculator before quoting them.
One departure from the final design above: it draws a CDN with cached menus in front of the API. On AWS, Module 3 already decided not to cache menus in CloudFront, and the scraper still mostly hits a cache, just Valkey's instead of the edge's. Routing every API request through CloudFront only for this would add about $3,100 a month in request charges at the US rate (the 3.1 billion requests a month worked out under Argument 1, at $0.01 per 10,000), with India's edge rate higher still.
The architecture
%% caption: Rate limits on AWS. WAF on the load balancer does the IP seatbelts; the app does per-user and feature limits against its own Valkey.
flowchart TD
P([Phone]) --> ALB[Application Load Balancer]
WAF["AWS WAF<br/>IP seatbelts, cloud IPs strict"]:::hot -.- ALB
ALB --> APP["App service, ECS on Fargate<br/>per-user bucket, feature limits"]
APP --> VK[("ElastiCache Serverless for Valkey<br/>limits only")]:::hot
APP -- "OTP, after the global cap" --> SMS["AWS End User Messaging SMS<br/>monthly spend quota"]| Generic box | AWS service | The setting that matters |
|---|---|---|
| CDN with cached menus | Not on the API path: menus stay in Valkey (Module 3) | CloudFront keeps serving dish photos only |
| nginx IP seatbelts | AWS WAF web ACL on the ALB | Rate-based rules with 60-second windows; strict ones scoped to cloud IPs |
| Redis for limits | ElastiCache Serverless for Valkey, separate from the menu cache | A 10 ms client timeout, and hash tags for multi-key scripts |
| SMS provider | AWS End User Messaging SMS | The monthly spending quota, and Indian sender registration |
Argument 1: where the IP seatbelt runs, and how it spots cloud IPs
WAF can attach to CloudFront or to the ALB. Two facts decide it. AWS's IP-reputation rule groups look at the request's source IP, and Tiffin's API traffic doesn't pass through CloudFront: phones connect straight to the ALB. So the seatbelt goes on the ALB, where the source is the phone. (If the API ever moves behind CloudFront, move the web ACL with it: an ALB behind CloudFront only sees CloudFront's addresses.)
The lab's rules translate directly:
- Mobile and home IPs: a rate-based rule, 6,000 requests per 60-second window per IP. WAF's minimum window is 60 seconds, which is fine for a seatbelt.
- Cloud IPs: add AWS's managed
AWSManagedRulesAnonymousIpListrule group, but set its rules to Count so they only label requests. Then a second rate-based rule, limited to requests carrying theHostingProviderIPListlabel, allows 60 a minute. - The gap: AWS documents that its hosting-provider list doesn't include AWS's own IP addresses, so plan as if a scraper renting EC2 instances slips past it. AWS publishes its IP ranges, so keep a WAF IP set built from that list, refreshed on a schedule, and give it the same strict limit.
WAF counts across the whole load balancer, not per server as nginx did, so the limit is fleet-wide. Its counts are approximate, though, so a client can get somewhat past the number before being limited. That's fine for a seatbelt, and it's exactly why the coupon, OTP and order limits stay in the app, where they're exact.
The cost: WAF charges $0.60 per million requests it inspects. Tiffin peaks at 3,600 requests a second; assume about a third of that, 1,200 a second, averaged over the day:
1,200/s x 2.6 million s = 3.1 billion requests a month x $0.60 per million = ~$1,870
plus the web ACL ($5) and its rules and rule group ($1 each) = ~$1,880
That's the biggest line in this section. It buys the scraper fix, and it's the place to add bot rules later without touching the app. (The Bitly lab also attaches its WAF to the ALBs, but for a different reason: there CloudFront answers most requests, the bots land on cache misses, and the ALB has to read the client's address from a header CloudFront adds.)
Argument 2: a cache just for counters
The lab gives limits their own Redis, so a cache flush can't reset everyone's counters. On ElastiCache there are two ways to buy it.
Node-based: one cache.r7g.large primary + one replica in another zone
2 x $0.1752 x 730 h ~ $256 a month
Serverless: ~5,000 calls/s at peak, ~1,700/s on average = 4.4 billion calls a month
at ~2 ECPU each (Lua scripts cost more than a GET) x $0.0023 per million ~ $20
+ storage, billed at its 100 MB minimum (the counters are tiny) ~ $6
~ $26 a month
At this size, ElastiCache Serverless wins by ten times, and it runs across Availability Zones without a replica to manage. Two things to check before relying on it. First, it adds a proxy hop, so measure the limiter's p99 against the lab's 10 ms timeout before going live. Second, a Lua script that touches several keys needs them in the same hash slot. Name keys with a hash tag, like {user:881}:bucket and {user:881}:orders, so one user's keys always sit together.
The node-based cluster becomes the cheaper choice once traffic is high enough that a busy node costs less than the ECPUs. The rate limiter lab reaches that point at a far larger scale.
Argument 3: the SMS bill's last line of defence
OTPs go through AWS End User Messaging SMS. Two things here matter before the first customer logs in.
The spending quota. Every account has a monthly SMS spending quota. New accounts start at $1.00 a month, so production needs an increase through Service Quotas. When sending would push the month past the quota, AWS stops sending, within minutes, so a burst can overshoot a little. That's a useful backstop for the lab's global cap, with two catches. It's monthly, so it can't replace the hourly cap that pages someone at 6,000 an hour. And when it trips, it stops all SMS, real customers' OTPs included, until the month ends or the quota is raised. Set it well above a normal month, say double, and alarm on your own OTP counts long before you reach it.
Registration. Sending to Indian numbers on local routes requires registering your sender details and message templates before you can send. It takes time, so start the paperwork weeks before launch, not the week of.
The lab's ₹0.25 per SMS is the provider price this design was costed on. Check AWS's current price for Indian destinations before comparing.
What it costs
| Line | Monthly, roughly |
|---|---|
| AWS WAF on the ALB: 3.1 billion requests, the ACL and its rules | ~$1,880 |
| ElastiCache Serverless for Valkey, limits only | ~$26 |
| SMS for OTPs | at the provider's per-message price, as in the lab |
| Total (excluding SMS) | ~$1,900 |
That's about 60 cents per million requests, almost all of it WAF. The limits themselves cost about as much as a team lunch. The protection at the edge is what you're paying for.
The same design on GCP and Azure
| Role | AWS | Google Cloud | Azure |
|---|---|---|---|
| IP limits at the front door | AWS WAF rate-based rules on the ALB | Cloud Armor rate limiting on the load balancer | Azure WAF rate limit rules on Application Gateway or Front Door |
| Counter store | ElastiCache Serverless for Valkey | Memorystore | Azure Managed Redis |
| OTP SMS | AWS End User Messaging SMS | Firebase Authentication phone sign-in, or a third-party SMS provider | Azure Communication Services SMS |
The design is the same everywhere: rough limits at the edge, exact limits in the app, and a hard cap on anything that costs money per message.
OTPs stop on the 22nd
Tiffin has had a great month: a big marketing push brought in many new customers. On the evening of the 22nd, every login fails with "We couldn't send your code". The app's global OTP cap hasn't tripped, and there's no sign of an attack. What happened, and what do you change so it can't happen again?
Show one answer
The account hit its monthly SMS spending quota. Growth, not an attack, pushed the month's OTP spend past the quota set when Tiffin launched, and AWS stopped sending every SMS within minutes. The app's hourly cap never tripped, because no single hour was unusual.
Right now: request a quota increase through Service Quotas, and meanwhile offer another way to log in if you have one (an email link, say). For the future: set the quota from current traffic with room for growth, review it monthly, and alarm on the month's running OTP count at 50% and 80% of what the quota allows. The quota is there to stop a disaster, so it should never be the first thing to notice a good month.
What to remember
- Start from real traffic. Every limit should sit well above your busiest real users and be checked in shadow mode before it blocks anyone.
- No single key works everywhere: IP before login (generous, stricter for cloud ranges), user ID after, phone number and a global total for OTPs.
- Put cheap, rough limits at the edge and strict, business-aware limits inside the features.
- A global cap turns an open-ended bill into a known worst case. Pair it with an alarm far below the cap.
- Decide fail open or fail closed per limit, based on whether it protects capacity or money and security.
- Rate limits bound damage. They don't replace caching, idempotency keys or bot detection.
- On AWS, IP seatbelts belong in WAF wherever it sees the real client IP (the ALB here, because the API doesn't pass through CloudFront), and exact business limits stay in the app. Managed IP lists have gaps, such as AWS's own address ranges.
- A provider's spending quota is a monthly backstop that stops everyone, not a replacement for your own hourly cap and alarms.