{"id":"design-url-shortener","title":"Design a URL shortener (like TinyURL or Bitly)","sections":[{"id":"overview","title":"Overview","html":"<p>A URL shortener takes a long web address, such as <code>https://en.wikipedia.org/wiki/Representational_state_transfer#Architectural_constraints</code>, and gives back a short alias such as <code>https://tiny.cc/r7k2x9</code>. When someone opens the short link, the service redirects them to the original address.</p>\n<p>It's a classic interview question because the product is simple and the design still makes you decide real things: how to generate short codes that never collide, which HTTP redirect status to send, how to shard a table with billions of rows, how big the cache needs to be, and how to count clicks without slowing down redirects.</p>\n<p>It builds on the primer's <a href=\"/learn/database\">database</a>, <a href=\"/learn/cache\">cache</a> and <a href=\"/learn/asynchronism\">asynchronism</a> chapters, and on the extra chapters on <a href=\"/learn/unique-ids\">unique IDs</a> and <a href=\"/learn/rate-limiting\">rate limiting</a>.</p>","headings":[]},{"id":"requirements-and-estimates","title":"Requirements and estimates","html":"<h3 id=\"requirements-and-estimates\">Requirements and estimates</h3>\n<h4 id=\"functional-requirements\">Functional requirements</h4>\n<ol>\n<li><strong>Shorten:</strong> given a valid long URL, return a unique short link.</li>\n<li><strong>Redirect:</strong> opening the short link sends the browser to the original URL.</li>\n<li><strong>Custom alias (optional):</strong> a user can ask for a specific code, such as <code>https://tiny.cc/my-portfolio</code>.</li>\n<li><strong>Expiry (optional):</strong> a link can have an expiry time. After it, the link returns <code>404 Not Found</code> or <code>410 Gone</code>.</li>\n<li><strong>Stats:</strong> the link's owner can see how many times it was clicked.</li>\n</ol>\n<h4 id=\"non-functional-requirements\">Non-functional requirements</h4>\n<ol>\n<li><strong>Fast redirects.</strong> A redirect should spend only a few milliseconds inside our system; aim for under 20 ms at the 99th percentile, measured at the server. The user is waiting to get somewhere else.</li>\n<li><strong>High availability.</strong> Short links get printed, emailed and posted everywhere. If the redirect service is down, all of them break at once, so redirects matter more than link creation.</li>\n<li><strong>Read-heavy.</strong> Links are created once and opened many times. Assume 100 redirects for every new link.</li>\n<li><strong>Short codes.</strong> Codes should be as short as practical, and not guessable in sequence.</li>\n</ol>\n<h4 id=\"out-of-scope\">Out of scope</h4>\n<p>Click-fraud detection, marketing campaign tools, billing tiers and link categorization.</p>\n<h4 id=\"capacity-estimates\">Capacity estimates</h4>\n<p>These are assumptions for a popular public service, not figures from any real one:</p>\n<ul>\n<li><strong>Writes:</strong> 100 million new links a month. 100,000,000 ÷ (30 × 86,400 s) ≈ <strong>39 writes a second</strong> on average, or about <strong>120 a second</strong> at a 3× peak.</li>\n<li><strong>Reads:</strong> at 100:1, that's 10 billion redirects a month. 10,000,000,000 ÷ (30 × 86,400 s) ≈ <strong>3,860 redirects a second</strong> on average, or about <strong>11,600 a second</strong> at a 3× peak.</li>\n<li><strong>Records:</strong> 100M a month × 12 × 5 years = <strong>6 billion links</strong> over five years.</li>\n<li><strong>Record size:</strong> an 8-byte ID, a code of up to 8 bytes, a long URL averaging about 100 bytes, two 8-byte timestamps and an owner ID come to roughly 150 bytes of data. Indexes, row headers and free space in pages add a lot on top, so budget <strong>500 bytes per link</strong>.</li>\n<li><strong>Storage:</strong> 6 billion × 500 bytes = <strong>3 TB</strong> over five years, before replication. That's not much disk. The challenge is serving the read rate with low latency, which is a job for a cache.</li>\n<li><strong>Cache:</strong> 10 billion ÷ 30 ≈ 333 million redirects a day. Clicks on links are very uneven: a small share of links gets most of the traffic. A common rule of thumb is to cache enough for 20% of a day's requests. Even in the worst case, where every one of the 333 million requests is for a different link, 20% of that is 67 million entries × 500 bytes ≈ <strong>33 GB</strong>. That fits in memory on one large cache server; in practice you run a small replicated cluster for availability.</li>\n</ul>\n<p>See <a href=\"/learn/estimation\">estimation</a> for more on this kind of arithmetic.</p>","headings":[{"id":"requirements-and-estimates","title":"Requirements and estimates","depth":3},{"id":"functional-requirements","title":"Functional requirements","depth":4},{"id":"non-functional-requirements","title":"Non-functional requirements","depth":4},{"id":"out-of-scope","title":"Out of scope","depth":4},{"id":"capacity-estimates","title":"Capacity estimates","depth":4}]},{"id":"api-design-and-http-redirects","title":"API design and HTTP redirects","html":"<h3 id=\"api-design-and-http-redirects\">API design and HTTP redirects</h3>\n<h4 id=\"rest-api\">REST API</h4>\n<p>Creating a link:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">POST</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> /api/v1/urls</span></span>\n<span class=\"line\"><span style=\"color:#22863A;--shiki-dark:#85E89D\">Content-Type</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> application/json</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"long_url\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"https://example.com/very/long/article?id=49281\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"custom_alias\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"cool-article\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"expires_at\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1735689600</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">}</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">HTTP</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">/</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1.1</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 201</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> Created</span></span>\n<span class=\"line\"><span style=\"color:#22863A;--shiki-dark:#85E89D\">Content-Type</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> application/json</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"short_code\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"cool-article\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"short_url\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"https://tiny.cc/cool-article\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"created_at\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1704067200</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">}</span></span></code></pre>\n<p><code>custom_alias</code> and <code>expires_at</code> are optional. <code>expires_at</code> is a Unix timestamp in seconds.</p>\n<p>Following a link:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">GET</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> /cool-article </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">HTTP</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">/</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1.1</span></span>\n<span class=\"line\"><span style=\"color:#22863A;--shiki-dark:#85E89D\">Host</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> tiny.cc</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">HTTP</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">/</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1.1</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 302</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> Found</span></span>\n<span class=\"line\"><span style=\"color:#22863A;--shiki-dark:#85E89D\">Location</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> https://example.com/very/long/article?id=49281</span></span></code></pre>\n<p>Reading stats:</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">GET</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> /api/v1/urls/cool-article/stats </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">HTTP</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">/</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1.1</span></span></code></pre>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">HTTP</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">/</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1.1</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 200</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> OK</span></span>\n<span class=\"line\"><span style=\"color:#22863A;--shiki-dark:#85E89D\">Content-Type</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">:</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> application/json</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">{</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"short_code\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\">\"cool-article\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"clicks\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">14205</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">  \"created_at\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">: </span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">1704067200</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">}</span></span></code></pre>\n<h4 id=\"301-vs-302-and-307-308\">301 vs 302 (and 307, 308)</h4>\n<p>The status code you redirect with decides whether browsers come back to you on the next click.</p>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th></th><th>301 Moved Permanently</th><th>302 Found / 307 Temporary Redirect</th></tr></thead><tbody><tr><td>Browser caching</td><td>Browsers may cache it, and without a <code>Cache-Control</code> header they often keep it for a long time. Later clicks can go straight to the target.</td><td>Not cached unless you add caching headers. Every click comes back to the shortener.</td></tr><tr><td>Load on your servers</td><td>Lower: repeat visits from the same browser skip you.</td><td>Higher: every click is a request.</td></tr><tr><td>Click counts</td><td>Undercounted: cached repeat visits are invisible.</td><td>Accurate: every click can be logged.</td></tr><tr><td>Changing or disabling a link</td><td>Slow: browsers that cached the redirect keep using it.</td><td>Immediate.</td></tr></tbody></table></div>\n<p>307 and 308 are the strict versions of 302 and 301: they require the browser to repeat the request with the same method and body. For links people click, which are always <code>GET</code>, 302 and 307 behave the same, and so do 301 and 308.</p>\n<p>A reasonable answer: use <strong>302</strong> if the product sells click analytics or needs to disable bad links quickly, which is the case for most commercial shorteners. Use <strong>301</strong> if you mainly want to reduce load and don't need per-click data. With a 301 you can still send <code>Cache-Control: max-age=…</code> to limit how long browsers keep it.</p>","headings":[{"id":"api-design-and-http-redirects","title":"API design and HTTP redirects","depth":3},{"id":"rest-api","title":"REST API","depth":4},{"id":"301-vs-302-and-307-308","title":"301 vs 302 (and 307, 308)","depth":4}]},{"id":"generating-the-short-code","title":"Generating the short code","html":"<h3 id=\"generating-the-short-code\">Generating the short code</h3>\n<p>Short codes use the characters <code>0-9</code>, <code>a-z</code> and <code>A-Z</code>: 62 symbols, so this is called <strong>Base62</strong>. How long do codes need to be?</p>\n<ul>\n<li>6 characters: 62⁶ ≈ 56.8 billion codes.</li>\n<li>7 characters: 62⁷ ≈ 3.52 trillion codes.</li>\n</ul>\n<p>We expect 6 billion links in five years. Six characters would technically be enough, but seven gives about <strong>590 times</strong> the needed space. That headroom matters because it also makes codes hard to guess: a random 7-character code hits an existing link only about once in 590 tries.</p>\n<p>There are two main ways to produce a code.</p>\n<h4 id=\"approach-a-hash-the-long-url\">Approach A: hash the long URL</h4>\n<ol>\n<li>Hash the long URL, for example with MD5: <code>MD5(\"https://example.com\") = c984d06aafbecf6bc55569f964148ea3</code>.</li>\n<li>Turn the first bits of the hash into a number, take it modulo 62⁷, and Base62-encode it to get 7 characters. Don't just take the first 7 hex characters: hex has only 16 symbols, so 7 of them give 16⁷ ≈ 268 million codes, far too few.</li>\n<li>Insert the code with a unique constraint. Because you've thrown away most of the hash, two different URLs will sometimes get the same code. The <a href=\"https://en.wikipedia.org/wiki/Birthday_problem\" target=\"_blank\" rel=\"noopener noreferrer\">birthday problem</a> says this starts happening long before the space is full: with 3.52 trillion codes, there's a 50% chance of at least one collision after only about 2.2 million links.</li>\n<li>On a collision, the insert fails. Append a counter or salt to the URL, hash again, and retry until the insert succeeds.</li>\n</ol>\n<p>The upside is that the same long URL always maps to the same code, so you can deduplicate if you want to. The downside is that every create is a check-and-retry against the database, and the retries grow as the table fills.</p>\n<h4 id=\"approach-b-unique-counter-plus-base62\">Approach B: unique counter plus Base62</h4>\n<p>Split the problem in two: first produce an integer that's guaranteed to be unique, then write that integer in Base62.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">CHARS</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> =</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\"</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">def</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> id_to_base62</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(num: </span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">int</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">) -> </span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">str</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">    if</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> num </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">==</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 0</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">        return</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> CHARS</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">[</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">0</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">]</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    result </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">=</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> []</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">    while</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> num </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">></span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 0</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">:</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">        result.append(</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">CHARS</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">[num </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">%</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 62</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">])</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">        num </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">//=</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\"> 62</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">    return</span><span style=\"color:#032F62;--shiki-dark:#9ECBFF\"> \"\"</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">.join(</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">reversed</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(result))</span></span></code></pre>\n<p>With this alphabet, ID 11,157,117,118 encodes to <code>cb47PU</code> and ID 100,000,000,000 to <code>1L9zO9O</code>. Different integers always produce different strings, and you can decode a string back to its integer, so there are <strong>no collisions to check for</strong>.</p>\n<p>The integers should come from a counter, not from a Snowflake-style 64-bit ID. A Snowflake ID packs a timestamp into the high bits, so the numbers are huge from day one and encode to 11 characters. A counter starting at 1 stays within 7 characters until it passes 62⁷ − 1, about 3.52 trillion.</p>\n<p>Custom aliases share the same namespace, so a user could claim a code the counter will produce later. Either check generated codes against the unique constraint too, or keep the two apart, for example by requiring custom aliases to be at least 8 characters or to contain a <code>-</code>.</p>\n<h5 id=\"generating-unique-integers-across-servers\">Generating unique integers across servers</h5>\n<p>A single database sequence would work at 120 writes a second, but it's a single point of failure. Two common ways to spread the work:</p>\n<ol>\n<li><strong>Range allocation.</strong> A small coordinator (a ZooKeeper node, a row in a strongly consistent database, or a Redis key with persistence) holds the next free range. Each API server claims a block, say 1,000,000 IDs, and hands them out from memory. When the block runs out, it claims another. If a server crashes, the unused part of its block is lost, but no ID is ever issued twice.</li>\n<li><strong>Key generation service (KGS).</strong> A separate service generates random 7-character codes ahead of time, filters out offensive words, and stores them as unused. API servers fetch batches of codes and use them directly. This spends extra storage on unused keys, and the KGS must make sure no two servers get the same batch, but it gives random-looking codes with no math at request time.</li>\n</ol>\n<p>The <a href=\"/learn/unique-ids\">unique IDs</a> chapter compares these and other schemes in more detail.</p>","headings":[{"id":"generating-the-short-code","title":"Generating the short code","depth":3},{"id":"approach-a-hash-the-long-url","title":"Approach A: hash the long URL","depth":4},{"id":"approach-b-unique-counter-plus-base62","title":"Approach B: unique counter plus Base62","depth":4}]},{"id":"high-level-architecture","title":"High-level architecture","html":"<h3 id=\"high-level-architecture\">High-level architecture</h3>\n<figure class=\"diagram\" data-text=\"Browser -> Load balancer (GET /code, POST /urls); Load balancer -> API servers; API servers -> Redis cache (1. lookup); API servers -> URL database (sharded) (2. on miss); API servers -> ID range allocator (claim range); API servers -> Click events (Kafka) (3. click event); Click events (Kafka) -> Analytics workers; Analytics workers -> ClickHouse (click stats) (aggregate)\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -13 640 398\" width=\"640\" role=\"img\" aria-label=\"URL shortener components\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"42\" x2=\"167\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"311\" y1=\"42\" x2=\"327\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"436\" y1=\"69\" x2=\"512\" y2=\"126\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"329\" y1=\"68.6\" x2=\"153\" y2=\"134.6\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"388\" y1=\"69\" x2=\"333.8\" y2=\"191\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"320\" y1=\"251\" x2=\"320\" y2=\"311\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"391\" y1=\"342\" x2=\"487\" y2=\"342\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"24\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">Browser</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"46.5\">Load balancer</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"46.5\">API servers</tspan></text><path class=\"dg-node dg-store\" d=\"M492,20 a68,7 0 0,1 136,0 v44 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,20 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"49.5\">Redis cache</tspan></text><path class=\"dg-node dg-store\" d=\"M492,138 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,138 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"161.5\">URL database</tspan><tspan x=\"560\" y=\"177.5\">(sharded)</tspan></text><rect class=\"dg-node dg-box\" x=\"12\" y=\"136\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"158.5\">ID range</tspan><tspan x=\"80\" y=\"174.5\">allocator</tspan></text><rect class=\"dg-node dg-queue\" x=\"252\" y=\"196\" width=\"136\" height=\"52\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"372\" y1=\"204\" x2=\"372\" y2=\"240\"></line><line class=\"dg-line\" x1=\"362\" y1=\"204\" x2=\"362\" y2=\"240\"></line><line class=\"dg-line\" x1=\"352\" y1=\"204\" x2=\"352\" y2=\"240\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"306\" y=\"218.5\">Click events</tspan><tspan x=\"306\" y=\"234.5\">(Kafka)</tspan></text><rect class=\"dg-node dg-box\" x=\"252\" y=\"316\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"320\" y=\"338.5\">Analytics</tspan><tspan x=\"320\" y=\"354.5\">workers</tspan></text><path class=\"dg-node dg-store\" d=\"M492,318 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M492,318 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"341.5\">ClickHouse</tspan><tspan x=\"560\" y=\"357.5\">(click stats)</tspan></text><rect class=\"dg-edge-bg\" x=\"88.9\" y=\"-4\" width=\"140.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"159\" y=\"9\" text-anchor=\"middle\">GET /code, POST /urls</text><rect class=\"dg-edge-bg\" x=\"446.1\" y=\"-9\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"479\" y=\"4\" text-anchor=\"middle\">1. lookup</text><rect class=\"dg-edge-bg\" x=\"438\" y=\"88.5\" width=\"72\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"474\" y=\"101.5\" text-anchor=\"middle\">2. on miss</text><rect class=\"dg-edge-bg\" x=\"201.9\" y=\"92.6\" width=\"78.2\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"241\" y=\"105.6\" text-anchor=\"middle\">claim range</text><rect class=\"dg-edge-bg\" x=\"312.5\" y=\"121\" width=\"96.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"360.9\" y=\"134\" text-anchor=\"middle\">3. click event</text><rect class=\"dg-edge-bg\" x=\"406.1\" y=\"333\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"439\" y=\"346\" text-anchor=\"middle\">aggregate</text></svg></div><figcaption>URL shortener components</figcaption></figure>\n<p>An <a href=\"/diagrams/url-shortener.html\" target=\"_blank\" rel=\"noopener noreferrer\">interactive version of this diagram</a> lets you pan, zoom and trace the request paths.</p>\n<h4 id=\"redirect-path\">Redirect path</h4>\n<ol>\n<li>The browser requests <code>GET /r7k2x9</code>.</li>\n<li>The load balancer sends it to any API server. The servers are stateless.</li>\n<li>The server looks up <code>r7k2x9</code> in Redis.</li>\n<li><strong>Cache hit:</strong> it returns <code>302 Found</code> with <code>Location: &#x3C;long_url></code>. Inside the data center this takes a millisecond or two.</li>\n<li><strong>Cache miss:</strong> it reads the URL database. If the row exists and hasn't expired, it writes it into Redis and returns the redirect. Otherwise it returns <code>404</code> (or <code>410</code> for an expired link). It's worth caching \"not found\" briefly too, so a burst of requests for a bad code doesn't hit the database each time.</li>\n<li><strong>Click event:</strong> the server publishes <code>{short_code, timestamp, ip, user_agent, referrer}</code> to a Kafka topic without waiting for analytics to process it. The redirect never blocks on analytics.</li>\n</ol>\n<h4 id=\"create-path\">Create path</h4>\n<ol>\n<li>The client sends <code>POST /api/v1/urls</code>.</li>\n<li>The server validates the URL and checks it against the abuse rules below.</li>\n<li><strong>Custom alias:</strong> it inserts the row; the unique constraint on <code>short_code</code> rejects a taken alias, and the server returns <code>409 Conflict</code>.</li>\n<li><strong>Generated code:</strong> it takes the next ID from its in-memory range, encodes it, and inserts the row.</li>\n<li>Optionally it writes the new mapping into Redis. New links are often clicked right after they're shared.</li>\n<li>It returns <code>201 Created</code> with the short URL.</li>\n</ol>","headings":[{"id":"high-level-architecture","title":"High-level architecture","depth":3},{"id":"redirect-path","title":"Redirect path","depth":4},{"id":"create-path","title":"Create path","depth":4}]},{"id":"data-storage-and-sharding","title":"Data storage and sharding","html":"<h3 id=\"data-storage-and-sharding\">Data storage and sharding</h3>\n<h4 id=\"schema\">Schema</h4>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">CREATE</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> TABLE</span><span style=\"color:#6F42C1;--shiki-dark:#B392F0\"> urls</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> (</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    short_code  </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">VARCHAR</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">16</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">)   </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">NOT NULL</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> PRIMARY KEY</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    id          </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">BIGINT</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">        UNIQUE</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    long_url    </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">VARCHAR</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">(</span><span style=\"color:#005CC5;--shiki-dark:#79B8FF\">2048</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">) </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">NOT NULL</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    user_id     </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">BIGINT</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    created_at  </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">TIMESTAMP</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">     NOT NULL</span><span style=\"color:#D73A49;--shiki-dark:#F97583\"> DEFAULT</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> CURRENT_TIMESTAMP,</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">    expires_at  </span><span style=\"color:#D73A49;--shiki-dark:#F97583\">TIMESTAMP</span><span style=\"color:#D73A49;--shiki-dark:#F97583\">     NULL</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">,</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">    INDEX</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> idx_user (user_id),</span></span>\n<span class=\"line\"><span style=\"color:#D73A49;--shiki-dark:#F97583\">    INDEX</span><span style=\"color:#24292E;--shiki-dark:#E1E4E8\"> idx_expires (expires_at)</span></span>\n<span class=\"line\"><span style=\"color:#24292E;--shiki-dark:#E1E4E8\">);</span></span></code></pre>\n<p>The redirect path only ever looks up by <code>short_code</code>, so that's the primary key. <code>id</code> is null for custom aliases. <code>idx_user</code> serves \"list my links\", and <code>idx_expires</code> serves the cleanup job.</p>\n<p>There are no joins and no multi-row transactions on the hot path, so a key-value or wide-column store (DynamoDB, Cassandra) works as well as a relational one. Pick whichever your team runs well.</p>\n<h4 id=\"sharding\">Sharding</h4>\n<p>3 TB fits on one machine's disks, but splitting the table across shards spreads the cache-miss reads and writes, keeps each shard's indexes small enough to stay in memory, and limits what a single failure takes out.</p>\n<ul>\n<li><strong>By user ID: a poor fit.</strong> One account that creates millions of links makes a hot shard, and a redirect only knows the code, not the user, so it would have to ask every shard.</li>\n<li><strong>By hash of the short code: the usual choice.</strong> <code>shard = hash(short_code) mod N</code>. Every redirect knows the code, so it goes straight to one shard, and hashing spreads codes evenly. Use <a href=\"/learn/consistent-hashing\">consistent hashing</a> or a fixed number of logical shards mapped to machines, so adding a machine moves only a fraction of the data.</li>\n</ul>\n<p>Each shard has replicas. Reads that miss the cache can go to replicas; a link created a moment ago might not be on a replica yet, so fall back to the primary on a miss for very new codes.</p>","headings":[{"id":"data-storage-and-sharding","title":"Data storage and sharding","depth":3},{"id":"schema","title":"Schema","depth":4},{"id":"sharding","title":"Sharding","depth":4}]},{"id":"caching-and-expiry","title":"Caching and expiry","html":"<h3 id=\"caching-and-expiry\">Caching and expiry</h3>\n<h4 id=\"cache-aside-with-lru-eviction\">Cache-aside with LRU eviction</h4>\n<ul>\n<li>The API server checks Redis first. On a miss it reads the database and writes the result to Redis. This is <strong>cache-aside</strong>; the <a href=\"/learn/cache\">cache</a> chapter covers it and the other strategies.</li>\n<li>Set Redis to evict with an <strong>LRU</strong> policy (<code>allkeys-lru</code>) when memory is full. Links nobody clicks fall out; popular ones stay.</li>\n<li>Give entries a TTL too, so a disabled or edited link stops being served from cache within a bounded time even if nobody explicitly deletes it.</li>\n<li>A link that goes viral can make one Redis key very hot. An in-process cache of a few thousand of the hottest codes on each API server takes that load off Redis.</li>\n</ul>\n<h4 id=\"removing-expired-links\">Removing expired links</h4>\n<ol>\n<li><strong>Check on read.</strong> When a link is looked up, compare <code>expires_at</code> with the current time. If it has passed, return <code>410 Gone</code> and remove it from the cache. This alone is enough for correctness.</li>\n<li><strong>Background cleanup.</strong> A periodic job deletes expired rows to reclaim space. It uses the <code>expires_at</code> index and deletes in small batches, such as <code>DELETE FROM urls WHERE expires_at &#x3C; NOW() LIMIT 5000</code>, repeated with short pauses. Big deletes hold locks for a long time and cause replication lag.</li>\n</ol>\n<p>Decide whether an expired code can ever be reused. Reusing it means an old printed link silently points somewhere new, which is usually a bad surprise. With 3.5 trillion codes available, it's simplest never to reuse them.</p>","headings":[{"id":"caching-and-expiry","title":"Caching and expiry","depth":3},{"id":"cache-aside-with-lru-eviction","title":"Cache-aside with LRU eviction","depth":4},{"id":"removing-expired-links","title":"Removing expired links","depth":4}]},{"id":"security-rate-limiting-and-malicious-links","title":"Security, rate limiting, and malicious links","html":"<h3 id=\"security-rate-limiting-and-malicious-links\">Security, rate limiting, and malicious links</h3>\n<ol>\n<li><strong>Phishing and malware.</strong> Spammers use shorteners to hide where a link goes. When a link is created, check the destination against Google Safe Browsing and similar threat feeds, either before activating it or right after with a quick takedown path. Re-scan popular links periodically, because a harmless page can turn malicious later. This is another reason to prefer 302: you can disable a bad link and every browser sees the change.</li>\n<li><strong>Enumeration.</strong> If codes come from a plain counter, <code>r7k2x9</code> is followed by <code>r7k2xa</code>, and anyone can crawl every link in order, including ones their owners thought were private. Pass the counter through a reversible scrambling function before encoding it, such as a small Feistel network over the range 0 to 62⁷ − 1. Each input still maps to a distinct output in the same range, so codes stay unique and 7 characters long, but consecutive IDs come out looking random. Combined with a mostly empty code space, guessing becomes expensive. Links that must stay private still need access control, not just an obscure code.</li>\n<li><strong>Abuse of the create API.</strong> Apply per-IP and per-account <a href=\"/learn/rate-limiting\">rate limits</a>, for example a token bucket at the API gateway, so one client can't create millions of links.</li>\n</ol>","headings":[{"id":"security-rate-limiting-and-malicious-links","title":"Security, rate limiting, and malicious links","depth":3}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>Why is encoding a unique counter in Base62 usually preferred over truncating an MD5 hash of the URL?</summary>\n<p>Truncating the hash throws away most of its bits, so different URLs collide, and the birthday problem means it starts early: with 62⁷ possible codes, a collision is more likely than not after only a couple of million links. Each create then needs an insert that might fail and a retry loop, and retries get more frequent as the table grows.</p>\n<p>Encoding a unique counter can't collide, because different integers always have different Base62 strings. The create is a single insert with no retry loop. The cost is that you need a way to hand out unique integers across servers, such as range allocation, and you lose the property that the same URL always gets the same code.</p>\n</details>\n<details>\n<summary>Why shouldn't the redirect handler increment a click counter in the URL database?</summary>\n<p>It would turn every redirect, about 11,600 a second at peak, into a database write. Worse, a viral link sends thousands of requests a second to update the same row, and those updates queue up behind the row lock. Redirect latency climbs and database connections run out.</p>\n<p>Instead, the redirect path only reads, mostly from cache, and publishes a click event to Kafka without waiting. Workers consume the events in batches and write aggregated counts to an analytics store such as ClickHouse. The stats page shows numbers that are a few seconds behind, which is fine.</p>\n</details>\n<details>\n<summary>An API server using range allocation crashes halfway through its block of one million IDs. What happens to the unused IDs, and is that a problem?</summary>\n<p>They're never issued. Say the server held 1,000,001 to 2,000,000 and had used up to 1,050,000: the remaining 950,000 are skipped, and the next block the coordinator hands out starts at 2,000,001. Nothing is issued twice, so uniqueness holds. Losing 950,000 IDs out of about 3.5 trillion 7-character codes is negligible. The only visible effect is a gap in the sequence, which nobody depends on.</p>\n</details>\n<details>\n<summary>The product team wants to disable a phishing link that's being shared widely. Which redirect status makes this possible, and what else needs to happen?</summary>\n<p>With 302, every click comes back to the shortener, so marking the link disabled in the database takes effect as soon as the cache stops serving the old entry. Delete the key from Redis (and from any in-process caches) when you disable it, and keep TTLs short enough to bound the delay if a delete is missed. With 301, browsers that already cached the redirect keep following it, and there's nothing the server can do about that.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc9110#section-15.4\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110: HTTP Semantics, section 15.4 (Redirection 3xx)</a>: the current definitions of 301, 302, 307 and 308. It replaced RFC 7231 in 2022.</li>\n<li><a href=\"https://en.wikipedia.org/wiki/Base62\" target=\"_blank\" rel=\"noopener noreferrer\">Base62 (Wikipedia)</a>: the alphabet and how numbers are encoded in it.</li>\n<li><a href=\"https://en.wikipedia.org/wiki/Birthday_problem\" target=\"_blank\" rel=\"noopener noreferrer\">Birthday problem (Wikipedia)</a>: why truncated hashes collide much sooner than intuition suggests.</li>\n<li><a href=\"https://developers.google.com/safe-browsing\" target=\"_blank\" rel=\"noopener noreferrer\">Google Safe Browsing</a>: the API for checking URLs against Google's lists of phishing and malware sites.</li>\n<li><a href=\"https://clickhouse.com/docs\" target=\"_blank\" rel=\"noopener noreferrer\">ClickHouse documentation</a>: the column-oriented database used here for click analytics.</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}],"sourceFiles":[]}