<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Krishanu Das</title>
    <description>The latest articles on DEV Community by Krishanu Das (@krishanu_das).</description>
    <link>https://dev.to/krishanu_das</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4126506%2F6c76c650-3770-4b79-b255-4b3f7d33f8f0.png</url>
      <title>DEV Community: Krishanu Das</title>
      <link>https://dev.to/krishanu_das</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/krishanu_das"/>
    <language>en</language>
    <item>
      <title>Designing a Notification System for an E-commerce App</title>
      <dc:creator>Krishanu Das</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:17:10 +0000</pubDate>
      <link>https://dev.to/krishanu_das/designing-a-notification-system-for-an-e-commerce-app-the-mistakes-i-made-along-the-way-2n51</link>
      <guid>https://dev.to/krishanu_das/designing-a-notification-system-for-an-e-commerce-app-the-mistakes-i-made-along-the-way-2n51</guid>
      <description>&lt;p&gt;Every e-commerce app sends notifications: your order shipped, your payment failed, a flash sale started. This post designs one end to end, with a focus on the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;An online shopping app has about 10 million customers. When something happens (an order is placed or shipped, a payment fails, a price drops), the customer gets a notification by &lt;strong&gt;push, email or SMS&lt;/strong&gt;. Some are urgent (a login code), some are marketing (a flash sale), and users choose which kinds and channels they want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requirements
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Types&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Critical&lt;/strong&gt; (OTP, password reset, login alert, payment failed), &lt;strong&gt;order updates&lt;/strong&gt; (confirmed, shipped, delivered, cancelled, refund), &lt;strong&gt;marketing&lt;/strong&gt; (flash sale, price drop, back in stock, abandoned cart)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User choice&lt;/td&gt;
&lt;td&gt;Users choose kinds and channels. Critical cannot be turned off. At least one channel is always on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Delivered"&lt;/td&gt;
&lt;td&gt;The provider confirms the notification reached the device or inbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;History&lt;/td&gt;
&lt;td&gt;Users see their last 90 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;OTP within 5 s; payment failed and order updates within 30 s; marketing within minutes (a 5M-user campaign finishes within 30 min)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failures&lt;/td&gt;
&lt;td&gt;Critical messages must not be lost or sent twice; a stale marketing offer is worse than a missed one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Out of scope:&lt;/strong&gt; the services that trigger notifications, and the providers (APNs/FCM, an email provider, an SMS gateway).&lt;/p&gt;

&lt;h2&gt;
  
  
  Capacity estimation
&lt;/h2&gt;

&lt;p&gt;Inputs: 10M users; about 50% have everything on (about 4 notifications a day), 30% transactional only (1), 15% custom (2), 5% almost muted (0.2); a flash-sale campaign of 5M users within 30 minutes; peak is about 10x the average; channel opt-in is push 60%, email 90%, SMS 25%, so &lt;strong&gt;each notification becomes about 1.75 provider calls&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per day&lt;/td&gt;
&lt;td&gt;5M x 4 + 3M x 1 + 1.5M x 2 + 0.5M x 0.2&lt;/td&gt;
&lt;td&gt;26.1M notifications/day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average&lt;/td&gt;
&lt;td&gt;26.1M / 86,400&lt;/td&gt;
&lt;td&gt;about 300 notifications/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normal peak&lt;/td&gt;
&lt;td&gt;300 x 10&lt;/td&gt;
&lt;td&gt;about 3,000 notifications/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campaign&lt;/td&gt;
&lt;td&gt;5M / (30 x 60 s)&lt;/td&gt;
&lt;td&gt;about 2,800 notifications/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Combined peak&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3,000 + 2,800&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 5,800 notifications/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider calls&lt;/td&gt;
&lt;td&gt;5,800 x 1.75&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;about 10,150 calls/s&lt;/strong&gt; (push 3,480, email 5,220, SMS 1,450)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB writes&lt;/td&gt;
&lt;td&gt;5,800 x (1 insert + 1.75 status updates)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;about 16,000 writes/s&lt;/strong&gt; (average about 830)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage, 90 days&lt;/td&gt;
&lt;td&gt;26.1M x 300 B x 90&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;about 0.7 TB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notifications, provider calls and database writes are different numbers, and the campaign is added on top of the normal peak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this tells us:&lt;/strong&gt; 0.7 TB fits on one machine, but 16,000 writes/s at peak does not suit a single relational database. The write path drives the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Calling services (orders, payments, marketing)
        |  POST /notifications  (request_id, user_id, type, expires_at, ...)
        v
+-----------------------------+
| Notification API            |  1. duplicate check  --&amp;gt; Redis (request_id, NX EX)
|  validate, reply 202        |  2. settings lookup  --&amp;gt; settings cache / table
|                             |  3. save "accepted"  --&amp;gt; Notifications store
|                             |  4. enqueue          --&amp;gt; topic below
+-----------------------------+
        |
        v
+--------------------------------------------------------------+
| Queues: one topic per channel x priority (9 topics)          |
|            critical        orders         marketing          |
|   push     push-critical   push-orders    push-marketing     |
|   email    email-critical  email-orders   email-marketing    |
|   sms      sms-critical    sms-orders     sms-marketing      |
+--------------------------------------------------------------+
        |
        v
+--------------------------------------------------------------+
| Worker groups (their own workers per topic)                  |
|   1. drop if past expires_at                                 |
|   2. own rate limiter (token bucket) toward the provider     |
|   3. call the provider; retry with backoff                   |
+--------------------------------------------------------------+
        |                    |                      |
        v                    v                      v
   APNs / FCM          Email provider          SMS gateway
   (a backup provider is kept for critical messages)
        |
        +--&amp;gt; status: sent / delivered / failed --&amp;gt; Notifications store
        +--&amp;gt; retries exhausted ------------------&amp;gt; Dead-letter topic

Recovery job: re-queues records still "accepted" after about a minute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The front door
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate&lt;/strong&gt; the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate check:&lt;/strong&gt; &lt;code&gt;SET request:&amp;lt;id&amp;gt; NX EX 86400&lt;/code&gt; in Redis. A repeat gets nil and is dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings lookup:&lt;/strong&gt; which channels the user has on, and whether marketing is allowed (cached).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save&lt;/strong&gt; the record with status &lt;code&gt;accepted&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enqueue&lt;/strong&gt; on the right topic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reply 202 Accepted.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We reply "accepted", not "sent". Sending happens in the background, so the caller is never slowed by a slow or failing provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why save before queueing?&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Order&lt;/th&gt;
&lt;th&gt;Crash in the gap&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Queue, then save&lt;/td&gt;
&lt;td&gt;Message queued, no record&lt;/td&gt;
&lt;td&gt;It is sent but untracked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Save, then queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Record exists, nothing queued&lt;/td&gt;
&lt;td&gt;Never sent, unless something notices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So we save first, and a &lt;strong&gt;recovery job&lt;/strong&gt; re-queues any record still &lt;code&gt;accepted&lt;/code&gt; after about a minute. The worst case is a duplicate, never a silent loss.&lt;/p&gt;

&lt;h3&gt;
  
  
  Idempotency
&lt;/h3&gt;

&lt;p&gt;The caller creates a &lt;code&gt;request_id&lt;/code&gt; once per event ("order 9001 shipped") and reuses it on every retry. Redis is the fast check, and the same &lt;code&gt;request_id&lt;/code&gt; is stored on the record as the durable safety net, because Redis can lose keys on a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Notifications&lt;/strong&gt; (Cassandra or DynamoDB)&lt;/td&gt;
&lt;td&gt;id, &lt;code&gt;user_id&lt;/code&gt;, type, template, status, timestamps, &lt;code&gt;expires_at&lt;/code&gt;, &lt;code&gt;request_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;High write rate. Partition by &lt;code&gt;user_id&lt;/code&gt;, sorted by &lt;code&gt;created_at&lt;/code&gt;, so "this user's latest 50" is one partition read. 90-day TTL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Request IDs&lt;/strong&gt; (Redis)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;request:&amp;lt;id&amp;gt;&lt;/code&gt; with an expiry&lt;/td&gt;
&lt;td&gt;Fast duplicate check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;User settings&lt;/strong&gt; (small table, cached)&lt;/td&gt;
&lt;td&gt;channels on, marketing on&lt;/td&gt;
&lt;td&gt;About 10M rows, read often, changed rarely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why not just Postgres?&lt;/strong&gt; The average (about 830 writes/s) is easy, but the peak and campaign burst (about 16,000/s) would need sharding and careful tuning. Cassandra and DynamoDB are built for heavy writes, and our reads are a simple key lookup. &lt;strong&gt;We give up&lt;/strong&gt; joins, flexible queries and multi-row transactions. TTL deletion isn't instant (DynamoDB can take days), so the history query also filters by date.&lt;/p&gt;

&lt;h2&gt;
  
  
  API
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /notifications
{
  "request_id": "...",     // idempotency key, same on every retry of an event
  "user_id": "...",
  "type": "otp | order_update | marketing",
  "order_id": "...",
  "template_id": "...",
  "expires_at": "..."      // never send late
}

202 Accepted
{ "notification_id": "...", "status": "accepted" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;PUT /users/{id}/settings&lt;/code&gt;, and &lt;code&gt;GET /users/{id}/notifications?limit=50&amp;amp;cursor=...&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Queues and workers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One topic per channel and priority.&lt;/strong&gt; Kafka has no priority queue, and neither does standard SQS, so priority means separate topics with separate workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partitions give parallelism, topics give priority.&lt;/strong&gt; Separate partitions inside one topic do not protect an OTP from a campaign, because the same worker group serves them. Critical topics need their &lt;strong&gt;own&lt;/strong&gt; workers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition sizing:&lt;/strong&gt; partitions &amp;gt;= calls/s / calls per worker, rounded up (you can add partitions later but never remove them). With an assumed 100 calls/s per worker, email at about 5,220 calls/s needs about 53 workers, so use 64 partitions on the marketing topic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition key:&lt;/strong&gt; &lt;code&gt;user_id&lt;/code&gt;, where one user's messages must stay in order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider limits:&lt;/strong&gt; if the email provider allows 4,000 calls/s and we need 5,220, workers run their own token bucket and send at most 4,000/s. The extras &lt;strong&gt;wait in the queue&lt;/strong&gt;; they are not dropped. Part of the allowed rate is reserved for critical messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry:&lt;/strong&gt; a worker checks &lt;code&gt;expires_at&lt;/code&gt; before sending. A stale OTP or an old offer is dropped and marked &lt;code&gt;expired&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery guarantee:&lt;/strong&gt; commit the offset after the provider call, which gives at-least-once. Workers must tolerate a rare duplicate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Failure handling
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;One call times out&lt;/th&gt;
&lt;th&gt;Whole provider is down&lt;/th&gt;
&lt;th&gt;Expired&lt;/th&gt;
&lt;th&gt;Retries exhausted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retry quickly, until &lt;code&gt;expires_at&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Backup provider, else another enabled channel&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;td&gt;Mark failed, alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Payment failed / order updates&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retry with backoff&lt;/td&gt;
&lt;td&gt;Backup provider or another channel&lt;/td&gt;
&lt;td&gt;Rarely applies&lt;/td&gt;
&lt;td&gt;Dead-letter topic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Marketing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Give up (at most one retry)&lt;/td&gt;
&lt;td&gt;Wait in the queue until recovery&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;td&gt;Drop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For critical messages a loss hurts and a duplicate is harmless; for marketing a miss is cheap and a duplicate annoys at scale.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backoff and dead letters:&lt;/strong&gt; wait longer between retries (1 s, 2 s, 4 s), cap the attempts, then park the message in a dead-letter topic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Circuit breaker:&lt;/strong&gt; after many failures in a row, stop calling a provider for a short while and test it occasionally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A timeout is ambiguous:&lt;/strong&gt; we don't know if the provider got the call. Push services have collapse ids and keys that replace an earlier message, but support varies, so we don't rely on them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Chose&lt;/th&gt;
&lt;th&gt;Gave up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;Cassandra / DynamoDB&lt;/td&gt;
&lt;td&gt;Joins, flexible queries, multi-row transactions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicates vs loss&lt;/td&gt;
&lt;td&gt;OTP and orders may duplicate; marketing may be missed&lt;/td&gt;
&lt;td&gt;A perfect "exactly once"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queues&lt;/td&gt;
&lt;td&gt;Topics per channel x priority, reserved capacity for critical&lt;/td&gt;
&lt;td&gt;More queues and workers; idle reserved capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Save before queue&lt;/td&gt;
&lt;td&gt;A possible duplicate after a crash&lt;/td&gt;
&lt;td&gt;Simplicity (needs a recovery job)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider pacing&lt;/td&gt;
&lt;td&gt;Our own limiter, extras wait&lt;/td&gt;
&lt;td&gt;Marketing can be delayed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>kafka</category>
      <category>redis</category>
    </item>
    <item>
      <title>Designing a Rate Limiter for an SMS API: The Mistakes I Made Along the Way</title>
      <dc:creator>Krishanu Das</dc:creator>
      <pubDate>Sun, 11 Oct 2026 16:11:07 +0000</pubDate>
      <link>https://dev.to/krishanu_das/designing-a-rate-limiter-for-an-sms-api-the-mistakes-i-made-along-the-way-1j6i</link>
      <guid>https://dev.to/krishanu_das/designing-a-rate-limiter-for-an-sms-api-the-mistakes-i-made-along-the-way-1j6i</guid>
      <description>&lt;p&gt;I'm working through system design as an AI engineer, and I'm writing up each problem as I go. This one is a rate limiter. It looked like the easy one. It caught me out in three places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Picture a public API that sends SMS messages, something like Twilio. Thousands of companies call it from their servers, on free, paid and enterprise plans.&lt;/p&gt;

&lt;p&gt;Two things keep going wrong. A buggy script on a free account loops forever and slows the API down for everyone else. And some customers send bursts to squeeze out more than they pay for.&lt;/p&gt;

&lt;p&gt;So we need a rate limiter: a cap on how many requests a client can make in a time window. Go over the cap and the API answers with HTTP 429 Too Many Requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Ask before designing
&lt;/h2&gt;

&lt;p&gt;I wrote my questions down first. These are the answers I settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Everything is limited together (sending and status checks), with one limit per client.&lt;/li&gt;
&lt;li&gt;The client is the API key, not the IP address. Lots of customers can share one IP, and one customer can use many.&lt;/li&gt;
&lt;li&gt;Over the limit gets a 429 with a &lt;code&gt;Retry-After&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;Free gets 60 requests per minute, paid gets 600, enterprise gets 6,000. About 90% of active accounts are free, 9% are paid and 1% are enterprise.&lt;/li&gt;
&lt;li&gt;100,000 accounts in total, and 20,000 of them active in the peak minute.&lt;/li&gt;
&lt;li&gt;Many API servers behind a load balancer, and the check can only add a few milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question I almost skipped was "who is the client?" Nearly everything else in the design hangs off the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Estimate the worst case
&lt;/h2&gt;

&lt;p&gt;I assumed every active account sits exactly at its limit.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Active accounts&lt;/th&gt;
&lt;th&gt;Requests/min&lt;/th&gt;
&lt;th&gt;Requests/sec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;18,000&lt;/td&gt;
&lt;td&gt;1,080,000&lt;/td&gt;
&lt;td&gt;18,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;1,800&lt;/td&gt;
&lt;td&gt;1,080,000&lt;/td&gt;
&lt;td&gt;18,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1,200,000&lt;/td&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;td&gt;3,360,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56,000&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's 56,000 requests per second, and the limiter checks every one of them, including the ones it ends up rejecting. A disk-based database can't take that on the hot path, so the counters have to live in memory.&lt;/p&gt;

&lt;p&gt;One more number. Paid and enterprise accounts are only 10% of accounts, but they're 68% of the traffic. I didn't think that mattered until the very end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Where does the count live?
&lt;/h2&gt;

&lt;p&gt;My first answer was that the limiter should "make sure the requests across servers add up to 60". That's just the requirement said again. It doesn't tell you how.&lt;/p&gt;

&lt;p&gt;Here's what actually goes wrong. If every server keeps its own count, the load balancer spreads one customer's requests across, say, 3 servers, and each server only sees a third of them. With a local limit of 60 on each, that customer can send 180 requests a minute and nobody complains.&lt;/p&gt;

&lt;p&gt;So the count has to live in one place that all the servers share. I'd use Redis, because it's in memory and handles this kind of load.&lt;/p&gt;

&lt;p&gt;There are two kinds of data here, and I'd keep them apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Config:&lt;/strong&gt; key X is on the free tier, so the limit is 60 a minute. This almost never changes, so each gateway can cache it in memory and refresh it every minute or so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State:&lt;/strong&gt; key X has used 43 so far. This changes on every request, so it lives in Redis.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'll come back to why that split matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race condition I missed
&lt;/h2&gt;

&lt;p&gt;Say a key has used 59 of its 60 and two requests arrive at the same moment on two different servers. Each server reads the count (59), sees it's under 60, lets the request through and writes 60. Both got through, when only one should have.&lt;/p&gt;

&lt;p&gt;My fix was to keep the servers in sync with each other. That doesn't work. They'd have to message each other on every request, which is slow and messy, and there's still a gap between "check" and "update" where both can decide at the same instant.&lt;/p&gt;

&lt;p&gt;The real fix is to stop doing three steps (read, compare, write) and make it one step inside the store. Redis can add 1 to a counter and hand back the new value as a single operation. Server 1 gets 60 and goes ahead. Server 2 gets 61 and gets a 429. It's the same idea as the atomic claim that makes sure only one worker picks up a job from a message queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Choosing the algorithm
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fixed window.&lt;/strong&gt; Put the current minute in the key (&lt;code&gt;X:12:00&lt;/code&gt;), increment it on every request, and let it expire. It's simple and cheap, one number per key.&lt;/p&gt;

&lt;p&gt;But look at the boundary. A client sends 60 requests at 12:00:59 and another 60 at 12:01:00. That's 120 requests in about 2 seconds, and nothing gets blocked.&lt;/p&gt;

&lt;p&gt;My first reaction was "that's fine, the window is whatever the platform defines". That's wrong. The limit exists to protect the backend, and the backend was sized for 60 a minute. If all 18,000 free accounts did this at once, it would see about 112,000 requests a second, double what it was built for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token bucket.&lt;/strong&gt; Each client has a bucket that holds up to a set number of tokens. Every request takes one, and an empty bucket means a 429. Tokens drip back in at a steady rate.&lt;/p&gt;

&lt;p&gt;The nice part is that you don't need a timer. You store two numbers per key, the tokens left and the time you last saw it, and you work out the refill only when a request shows up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tokens_now = min(capacity, tokens_saved + (now - last_seen) x refill_rate)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's a small example I worked by hand (capacity 3, refill 1 token a second, bucket starts full):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Tokens available&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Tokens left&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;t=0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;t=0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;t=0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;t=0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;t=1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;t=5&lt;/td&gt;
&lt;td&gt;3 (4 would have built up, capped at 3)&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;t=6&lt;/td&gt;
&lt;td&gt;3 (2 saved plus 1 refilled)&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I went with the token bucket because there's no boundary to exploit. What I give up is simplicity: two numbers per key instead of one, and the refill maths has to run as one atomic step, since it reads and writes two values.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Bucket size&lt;/th&gt;
&lt;th&gt;Refill&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;1 token/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;10 tokens/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;100 tokens/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing to know: a bucket the size of a full minute lets a client burst a whole minute's worth at once. If that's too generous, make the bucket smaller. That's a product decision more than a technical one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check itself
&lt;/h2&gt;

&lt;p&gt;Redis runs a Lua script as one uninterrupted step, so the whole check is atomic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;          &lt;span class="c1"&gt;-- tokens per second&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'TIME'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;-- Redis's own clock&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;math.floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HMGET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;math.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HSET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;math.ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;math.floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details. It uses Redis's clock, not each gateway's, because different machines have slightly different clocks and I don't want them disagreeing about "now". And idle keys expire on their own, so inactive accounts don't eat memory. A key that expired just starts with a full bucket, which is the right behaviour anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full picture
&lt;/h2&gt;

&lt;h2&gt;
  
  
  What the 429 should say
&lt;/h2&gt;

&lt;p&gt;My first answer was "tell them the calls for the minute are over". That's wrong for a token bucket, because there is no minute. Tokens come back continuously, so the useful thing to tell a client is how long until one token exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;429&lt;/span&gt; &lt;span class="ne"&gt;Too Many Requests&lt;/span&gt;
&lt;span class="na"&gt;Retry-After&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;X-RateLimit-Limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60&lt;/span&gt;
&lt;span class="na"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a free key that refills 1 token a second, an empty bucket means &lt;code&gt;Retry-After: 1&lt;/code&gt;. If it refilled 1 token every 2 seconds, it would be 2. Successful responses carry the &lt;code&gt;X-RateLimit&lt;/code&gt; headers too, so a well-behaved client can slow down before it hits the wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling it
&lt;/h2&gt;

&lt;p&gt;56,000 checks a second is within what a single Redis node can do for simple commands, but Lua scripts cost more, so I'd want headroom. I'd spread keys across several Redis nodes with consistent hashing on the API key, so adding a node only moves about 1/N of the keys.&lt;/p&gt;

&lt;p&gt;And a key that moves, or a counter that gets lost, isn't a disaster. The client just starts with a full bucket. Worst case, one extra burst.&lt;/p&gt;

&lt;p&gt;Replication is asynchronous, so after a failover the last few updates can be lost and a client might briefly get a bit more than its limit. For a rate limiter I'm happy with that. It picks availability over perfect accuracy.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the limiter itself breaks
&lt;/h2&gt;

&lt;p&gt;If Redis is unreachable, the limiter can't check counts, and it has to pick a default.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail open:&lt;/strong&gt; let requests through. Customers keep working, but nobody is limited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed:&lt;/strong&gt; reject requests. The backend is safe, but every customer gets errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first answer was to split by tier: fail open for paying customers, fail closed for free ones. Then I gave a reason that pointed the opposite way ("I'd rather give paying customers an error"), which is fail closed. I'd mixed the two up.&lt;/p&gt;

&lt;p&gt;Even after fixing that, the split doesn't hold up against the numbers. Paid and enterprise are 68% of the traffic, so failing open for them leaves two-thirds of everything unchecked. Failing closed for them means errors for the customers who pay. Failing closed for everyone means a protective component takes down the whole product.&lt;/p&gt;

&lt;p&gt;What I'd do instead is fail open for everyone, with a safety net. If a gateway can't reach Redis, it switches to a local in-memory bucket using the tier limit divided by the number of gateways. It's rough, but it still stops a runaway script, and it raises an alert straight away.&lt;/p&gt;

&lt;p&gt;This only works because each gateway already has the tier config cached. If tier info lived in Redis too, it would disappear in exactly the outage where I need it. That's the reason config and state live in different places.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Ask who the client is before anything else. The rest of the design hangs off it.&lt;/li&gt;
&lt;li&gt;A counter that each server keeps by itself isn't a limit. The count has to be shared.&lt;/li&gt;
&lt;li&gt;"Read, check, write" is a race. Make it a single atomic step inside the store.&lt;/li&gt;
&lt;li&gt;A fixed window leaks at the boundary. Check an algorithm against what the limit is protecting, not just against its own definition.&lt;/li&gt;
&lt;li&gt;Your 429 response has to match your algorithm. With a token bucket, tell the client how many seconds to wait, not that the minute is over.&lt;/li&gt;
&lt;li&gt;Decide what happens when the limiter fails before it happens, and check the decision against your own traffic numbers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Next I'm going to try the order notifications for something like Flipkart, and this time I'll attempt the whole design on my own before I look anything up.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Designing a URL Shortener From First Principles (and the Hashing Trap I Fell Into)</title>
      <dc:creator>Krishanu Das</dc:creator>
      <pubDate>Tue, 22 Sep 2026 07:14:46 +0000</pubDate>
      <link>https://dev.to/krishanu_das/designing-a-url-shortener-from-first-principles-and-the-hashing-trap-i-fell-into-2g6h</link>
      <guid>https://dev.to/krishanu_das/designing-a-url-shortener-from-first-principles-and-the-hashing-trap-i-fell-into-2g6h</guid>
      <description>&lt;p&gt;I'm working through system design as an AI engineer, and I'm writing up each problem as I go. This one is the classic: &lt;strong&gt;design a URL shortener like bit.ly.&lt;/strong&gt; It looks simple, but it has one design decision that catches people out, and I walked right into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two operations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Shorten:&lt;/strong&gt; submit a long URL, get back a short one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect:&lt;/strong&gt; visit the short URL, get sent to the original&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;POST /urls        { "longUrl": "...", "expiresAt": "..." }  → 201 { "shortUrl": "sho.rt/21" }&lt;br&gt;
GET  /{shortCode} → 302 redirect to longUrl&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Estimate before designing
&lt;/h2&gt;

&lt;p&gt;I anchored on a user base and derived everything else from it, rather than guessing RPS directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reads (clicks):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100M total users, 50M active per day&lt;/li&gt;
&lt;li&gt;~5 clicks per active user per day → &lt;strong&gt;250M clicks/day&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;250M ÷ 86,400 seconds ≈ &lt;strong&gt;2,900 RPS&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Writes (creating links):&lt;/strong&gt;&lt;br&gt;
Creating a link is much rarer than clicking one. My first estimate had more creates than clicks, which contradicted the read-heavy intuition, so I redid it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~5M users create links on a given day, ~5 links each → &lt;strong&gt;25M creates/day&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;25M ÷ 86,400 ≈ &lt;strong&gt;290 RPS&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's roughly a &lt;strong&gt;10:1 read/write ratio&lt;/strong&gt;, and it shapes everything below: the design effort belongs on the read path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Storage:&lt;/strong&gt;&lt;br&gt;
Each record holds the long URL (~100 bytes), short code (~10), creator ID (~8–16), created timestamp (~8), expiry (~8) and a click counter (~8). That rounds to ~150 bytes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;25M records/day × 365 days ≈ 9.1B records/year
9.1B × 150 bytes ≈ 1.37 TB/year
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1.37 TB a year fits on a single well-provisioned database.&lt;/strong&gt; Storage isn't the bottleneck here, so there's no reason to shard just because the problem "sounds like scale." The numbers should drive that call, not the vibe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Latency target:&lt;/strong&gt; 100–200 ms. A redirect is a lookup with no heavy computation in the path, so it should feel instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The architecture
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    U[User / Browser] --&amp;gt; LB[Load Balancer]
    LB --&amp;gt; A1[App Server 1]
    LB --&amp;gt; A2[App Server 2]
    LB --&amp;gt; A3[App Server N]
    A1 &amp;amp; A2 &amp;amp; A3 --&amp;gt; C[("Redis cache: shortCode to longUrl")]
    A1 &amp;amp; A2 &amp;amp; A3 --&amp;gt; DB[("Database: urls table + sequence")]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Stateless app servers sit behind a load balancer. A cache holds hot redirects, and a single relational database stores the records and generates IDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The hard part is generating the short code
&lt;/h2&gt;

&lt;p&gt;This is the actual design problem. There were three options:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    Q{"How do we generate the short code?"}
    Q --&amp;gt; H[Hash the long URL]
    Q --&amp;gt; R[Random string]
    Q --&amp;gt; K[Counter + Base62]
    H --&amp;gt; H1["Same URL always gives the same code: breaks per-user expiry and analytics"]
    R --&amp;gt; R1["Can collide: needs retry when the DB rejects a duplicate"]
    K --&amp;gt; K1["Unique by construction: needs one shared counter"]&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  The hashing trap
&lt;/h3&gt;

&lt;p&gt;My first instinct was to hash the long URL. It feels natural, since hashing turns a long input into a short output.&lt;/p&gt;

&lt;p&gt;Then consider this case. User A shortens &lt;code&gt;example.com/page&lt;/code&gt; in January for a 30-day campaign and wants their own click analytics. User B shortens the &lt;em&gt;same&lt;/em&gt; URL in July with a different expiry.&lt;/p&gt;

&lt;p&gt;Hashing is deterministic: the same input always gives the same output. Both users would get the &lt;strong&gt;same short code&lt;/strong&gt;, and therefore one shared record. Whose expiry wins? Whose clicks are whose?&lt;/p&gt;

&lt;p&gt;The real requirement is that &lt;strong&gt;each shorten request is its own record&lt;/strong&gt;, because ownership, expiry and analytics belong to the request, not to the URL. So the short code has to be generated independently of the URL's content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Random vs counter
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Random strings&lt;/strong&gt; mostly work, but they can collide. The database's unique constraint catches a collision, and you retry. As the table grows into billions of rows, collisions become less rare (the birthday problem).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A counter encoded in Base62&lt;/strong&gt; can't collide at all. Take an ever-increasing number and encode it with &lt;code&gt;[0-9a-zA-Z]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;125 = 2 × 62 + 1  →  "21"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No two counter values are the same, so no two codes are the same. There's no retry logic needed.&lt;/p&gt;

&lt;p&gt;How long does it last? With 7 characters, 62⁷ ≈ 3.5 trillion codes. At 9.1B links a year, that's &lt;strong&gt;over 380 years&lt;/strong&gt; of headroom.&lt;/p&gt;

&lt;h3&gt;
  
  
  The distributed counter problem
&lt;/h3&gt;

&lt;p&gt;There are many app servers. If each one keeps its own counter in memory, two servers can hand out the same number at the same moment.&lt;/p&gt;

&lt;p&gt;There are two standard fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Let the database own the counter&lt;/strong&gt; (an auto-increment column or a Postgres &lt;code&gt;SEQUENCE&lt;/code&gt;). The database guarantees uniqueness even under concurrent load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand out ranges.&lt;/strong&gt; Each server grabs a block of, say, 1,000 IDs at a time and assigns them locally. This reduces contention on the shared counter, at the cost of some wasted IDs if a server crashes mid-block.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;I went with option 1.&lt;/strong&gt; At ~290 creates/sec, a single database sequence handles the load comfortably, so ranges would solve a problem this system doesn't have. Match the complexity to the numbers you actually calculated.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Client
    participant S as App Server
    participant DB as Database
    C-&amp;gt;&amp;gt;S: POST /urls (longUrl, expiresAt)
    S-&amp;gt;&amp;gt;DB: nextval(url_seq)
    DB--&amp;gt;&amp;gt;S: 125
    S-&amp;gt;&amp;gt;S: base62(125) = "21"
    S-&amp;gt;&amp;gt;DB: INSERT shortCode, longUrl, createdBy, createdAt, expiresAt
    DB--&amp;gt;&amp;gt;S: OK
    S--&amp;gt;&amp;gt;C: 201 shortUrl = sho.rt/21&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Step 4: Make the read path fast
&lt;/h2&gt;

&lt;p&gt;With a 10:1 read/write ratio, redirects are where performance matters.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Index:&lt;/strong&gt; the short code is the primary key, so lookups are indexed automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache:&lt;/strong&gt; hot &lt;code&gt;shortCode → longUrl&lt;/code&gt; mappings live in Redis. The cache key is just the short code, because a redirect is public and identical for everyone.
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant C as Client
    participant S as App Server
    participant R as Redis
    participant DB as Database
    C-&amp;gt;&amp;gt;S: GET /21
    S-&amp;gt;&amp;gt;R: GET 21
    alt cache hit
        R--&amp;gt;&amp;gt;S: longUrl
    else cache miss
        R--&amp;gt;&amp;gt;S: null
        S-&amp;gt;&amp;gt;DB: SELECT longUrl WHERE shortCode = 21
        DB--&amp;gt;&amp;gt;S: longUrl
        S-&amp;gt;&amp;gt;R: SET 21 longUrl with TTL
    end
    S--&amp;gt;&amp;gt;C: 302 redirect to longUrl&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;Why 302 and not 301?&lt;/strong&gt; Browsers cache a 301 (permanent) redirect, so repeat clicks never reach the server and the click counts would be wrong. A 302 keeps every click visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd revisit next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guessable codes:&lt;/strong&gt; Sequential codes mean anyone can walk through &lt;code&gt;21&lt;/code&gt;, &lt;code&gt;22&lt;/code&gt;, &lt;code&gt;23&lt;/code&gt;... and find other people's links. If that matters, shuffle or obfuscate the ID before encoding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry cleanup:&lt;/strong&gt; Expired links need a background job to purge them, and the cache TTL shouldn't outlive the link's own expiry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache eviction:&lt;/strong&gt; Decide on a policy (LRU is the usual choice) and how big the cache needs to be.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Derive the numbers, don't guess them.&lt;/strong&gt; Total users, then active users, then per-user rate, then RPS. The same method works for storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity-check your estimates against each other.&lt;/strong&gt; My first write estimate exceeded my read estimate in a read-heavy system, and that contradiction was the signal to redo it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every system needs to shard.&lt;/strong&gt; 1.37 TB a year fits on one box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic isn't always what you want.&lt;/strong&gt; Hashing is ideal when identical inputs &lt;em&gt;should&lt;/em&gt; map together. Here they shouldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match solution complexity to real scale.&lt;/strong&gt; Use a database sequence at 290 writes/sec, and switch to ID ranges when the numbers say so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;This is part of a series where I learn system design in public. Next up: designing a rate limiter.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>backend</category>
      <category>architecture</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
