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