<?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 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>
