System Design Case Study: Designing a URL Shortener
A URL shortener is a read-heavy system: ~3,900 redirects against ~40 writes per second, so it needs a stateless redirect path, cache-aside, codes that never collide and are hard to guess, and 302 rather than 301 for analytics. A computed capacity table, a real measurement of hash collisions, counter + bijective mapping + base62, 301/302 curl output, aliases, expiration and abuse.
Version note: The code in this post was built and run with C# 14 and .NET 10; the redirect output comes from curl requests sent to a prototype written with ASP.NET Core 10.
Guess First
Bookstore's marketing team wants its own short link service for newsletter and SMS (Short Message Service) campaigns: links like bkst.re/c9Ac8PH, a report of how many clicks each campaign got, and links that expire when the campaign ends. In the first prototype the developer wrote Results.Redirect(url, permanent: true) for the redirect, that is a 301, and generates short codes from the database's identity value with base62: 1, 2, 3...
Two questions. In the campaign's second week the report shows the click count barely moving even though the same users keep clicking the link. Why? And what does a competitor learn by trying bkst.re/1, bkst.re/2, ...?
Requirements and Capacity Estimation
We follow the order from this series' "What Is System Design and How Do You Approach It? A 4-Step Method from Requirements to Deep Dive" post. Functional requirements: given a long URL (Uniform Resource Locator), produce a short link; redirect requests for the short link to the long URL; optional custom alias (bkst.re/autumn-sale) and expiration date; click count per link. Out of scope: a link editing UI, user management, a detailed analytics dashboard.
The non-functional requirements are more decisive. Redirects must be fast: this step sits between the user and the actual page, so p99 (the time 99% of requests stay under) shouldn't exceed 50 ms. The redirect path must be highly available: every link in the newsletter depends on the same service, and if it goes down, the campaign goes down. Short codes must never collide and shouldn't be easy to guess. A small delay or loss in click counts is acceptable; redirect speed matters more.
For the numbers I wrote down our assumptions and left the arithmetic to code: 100 million new links a month, a 100-to-1 read-to-write ratio, peak at 3 times the average, 500 bytes per link, 10 years of retention.
const double newLinksPerMonth = 100_000_000, readsPerWrite = 100, peakFactor = 3;
const double bytesPerLink = 500, years = 10, redirectResponseBytes = 500;
double writesPerSec = newLinksPerMonth / (30 * 86_400);
double readsPerSec = writesPerSec * readsPerWrite;
double totalLinks = newLinksPerMonth * 12 * years;
double redirectsPerDay = readsPerSec * 86_400;
writes/s avg 39, peak 116
redirects/s avg 3,858, peak 11,574
links in 10 years 12.0 billion, storage 6.0 TB
redirect egress avg 15.4 Mbit/s
cache for top 20% of a day's redirects: 33.3 GB (upper bound, before dedup)
62^7 = 3,521,614,606,208 codes, 0.34 % used after 10 years
The table shapes the design. Write load is small (40 per second), read load is 100 times that and reaches 11,500 per second at peak: this is a read-heavy system, and the redirect path is what needs to scale. 6 TB accumulates over ten years; a single database server can carry that, but with row count heading to 12 billion it matters that the key is small and the index tight. The cache line is an upper bound: since the same popular link gets clicked thousands of times a day, the real working set is far below that. And the 7-character base62 space (62^7 ≈ 3.5 trillion) is only 0.34% full even after ten years; 7 characters is enough.
API and Data Model
Three endpoints are enough:
POST /api/links:longUrlin the body, optionalcustomAliasandexpiresAt; returns 201 Created andshortUrl, or 409 Conflict if the alias is taken.GET /{code}(andHEAD): 302 and aLocationheader; an unknown code returns 404, an expired one 410 Gone.GET /api/links/{code}/stats: click counts, from the analytics store.
The data model is a Links table: Code (primary key, 7–30 characters), LongUrl, OwnerId, CreatedAt, ExpiresAt. Clicks aren't written to this table; updating the Links row on every click would add thousands of writes per second to the hottest rows of a read-heavy system. Clicks go to a separate event stream and are counted in batches. One detail: base62 is case-sensitive; abc and ABC are different codes. SQL (Structured Query Language) Server's default collation, on the other hand, is case-insensitive; unless the Code column is defined with a binary or case-sensitive collation, two different codes either trip the unique constraint or go to the wrong link.
High-Level Design

The two paths are separate. On the write path, the Link API (Application Programming Interface) validates the URL, generates the code and writes the row to the primary database. On the redirect path, the stateless redirect service checks the Redis cache first; on a miss it reads from the read replica, puts the result in the cache and returns 302. So as not to delay the response, the click is dropped onto a queue as an event; the analytics worker collects them and updates the counters. This split is a direct application of the idea in the series' "Asynchronous Processing: Queues, Workers, Fan-Out and Backpressure" post: only the work the user has to wait for stays on the redirect path. Because the redirect service is stateless, it scales out as far as you like, as in "From One Server to Millions of Users: Vertical and Horizontal Scaling and the Stateless Tier".
Deep Dive: Generating the Short Code
There are three ways. The first is hashing the long URL and taking the first 7 characters. It's tempting, because the same URL always gives the same code. But collisions are unavoidable in a truncated hash, and thanks to the birthday problem they start much earlier than you'd expect. I tried a million URLs in a 5-character space:
// SHA-256 + truncation in a 5-character space: how many collisions in 1,000,000 URLs?
long space5 = (long)Math.Pow(62, 5);
HashSet<string> seen = [];
int collisions = 0;
for (int i = 0; i < 1_000_000; i++)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes($"https://bookstore.example/books/{i}"));
long n = (long)(BitConverter.ToUInt64(hash, 0) % (ulong)space5);
if (!seen.Add(Base62.Encode(n, 5))) collisions++;
}
5-char hash codes: 519 collisions in 1,000,000 (birthday estimate ~546)
7-char hash codes after 10 years: ~20,445,167 expected collisions
The space is 916 million, a million are used (one in a thousand), and there are still 519 collisions; the approximation n²/2N predicts 546. With our assumption of 7 characters and 12 billion links, the expected number of collisions is over 20 million. Every collision needs "lengthen the code by one character, or salt the URL and hash again" logic, and checking for it needs a read on every write. The same URL giving the same code isn't as valuable as it seems either: two different campaigns may shorten the same URL and want their clicks counted separately.
The second is generating 7 random characters and relying on the unique constraint. Since only 0.34% of the space will ever be used, the chance of an insert colliding is at worst 3.4 in a thousand; on a collision, generating a new code and retrying is enough. Simple, unguessable, no coordination.
The third is a counter and base62. Collisions are impossible, but the answer to the second Guess First question is here: consecutive codes reveal the total number of links and the daily volume, and every code predicts the next one. A cheap way to keep the counter and close this leak is to pass the counter through a one-to-one (bijective) mapping over the space: multiply by a constant coprime with 62^7 and take the remainder. Each counter value maps to a different code, but consecutive counters don't give consecutive codes:
public static class ShortCode
{
public const int Length = 7;
public static readonly long Space = (long)Math.Pow(62, Length); // 3,521,614,606,208
private const long Multiplier = 2_166_810_999_823; // coprime with 62^7 (= 2^7 * 31^7)
private static readonly long Inverse = (long)BigInteger.ModPow(Multiplier, Totient() - 1, Space);
public static string FromCounter(long counter) =>
Base62.Encode((long)((UInt128)counter * Multiplier % (UInt128)Space), Length);
public static long ToCounter(string code) =>
(long)((UInt128)Base62.Decode(code) * (UInt128)Inverse % (UInt128)Space);
// phi(2^7 * 31^7) = 2^6 * 31^6 * 30
private static BigInteger Totient() => BigInteger.Pow(2, 6) * BigInteger.Pow(31, 6) * 30;
}
counter 1 -> c9Ac8PH -> back to 1
counter 2 -> EILEGoY -> back to 2
counter 3 -> qRVqPDp -> back to 3
counter 1,000,000 -> 4l8REHY -> back to 1,000,000
2,000,000 counters -> 2,000,000 distinct codes
This is obfuscation, not encryption: someone who knows the constant can reverse the mapping. But it makes codes unreadable to a casual observer and keeps the no-collision guarantee. The counter itself doesn't need a trip to a single database sequence on every write: with block allocation from the series' "Generating Unique IDs: Auto-Increment, UUIDv4, UUIDv7 and Snowflake" post, each Link API instance takes a block of 1,000 and hands it out from memory until it runs out. At 40 writes per second, that's a few sequence calls an hour. If the code being truly unguessable is a requirement (links sharing private documents, for example), the second way, a random code, is the right choice; in that case consider making the code 8–10 characters long too.
Deep Dive: 301 or 302?
The answer to the first Guess First question lies in the redirect's status code. I put two endpoints side by side, one returning 302 and the other 301. Since tools that build link previews send HEAD, the endpoints answer HEAD as well as GET; on my first attempt, using only MapGet, curl -I got 405 Method Not Allowed.
// 302: every click comes back to us (analytics, expiry and edits keep working); HEAD for link previews
app.MapMethods("/{code}", ["GET", "HEAD"], (string code) => Resolve(code, permanent: false));
// 301: only for comparison
app.MapMethods("/p/{code}", ["GET", "HEAD"], (string code) => Resolve(code, permanent: true));
IResult Resolve(string code, bool permanent)
{
if (!links.TryGetValue(code, out Link? link)) return Results.NotFound();
if (link.ExpiresAt <= DateTimeOffset.UtcNow) return Results.StatusCode(StatusCodes.Status410Gone);
return Results.Redirect(link.LongUrl, permanent);
}
$ curl -sI localhost:5392/c9Ac8PH
HTTP/1.1 302 Found
Date: Sun, 08 Nov 2026 01:13:23 GMT
Server: Kestrel
Location: https://bookstore.example/books/978-0134757599?utm_source=newsletter
$ curl -sI localhost:5392/p/c9Ac8PH
HTTP/1.1 301 Moved Permanently
Date: Sun, 08 Nov 2026 01:13:23 GMT
Server: Kestrel
Location: https://bookstore.example/books/978-0134757599?utm_source=newsletter
The two responses look nearly identical, but their HTTP (Hypertext Transfer Protocol) semantics differ. Under RFC (Request for Comments) 9110, a 301 is "heuristically cacheable": browsers and intermediate caches may store it even without a Cache-Control header, and browsers usually remember a permanent redirect for a long time. When the user clicks the same link a second time, the browser goes straight to the destination without touching us: the click isn't counted, the link can't be edited, and an expired link keeps working. A 302 isn't cached by default; every click passes through the redirect service. The cost is more load on the server; but the redirect count in the capacity table was already computed assuming every click reaches us. Because click counting and expiration are requirements, the choice is 302. For links that need no analytics and whose destination will never change, 301 reduces server load. If the method needs to be preserved across the redirect (forwarding a POST as a POST), the counterparts are 307 and 308.
Deep Dive: The Read Path and the Cache
Sending 11,500 peak redirects per second to the database every time is unnecessary: once a link is created it almost never changes, which makes it ideal for caching. The cache-aside approach from the series' "Caching Strategies: Cache-Aside, Write-Through, Write-Behind and Cache Stampede" post is the natural choice: the redirect service checks Redis first, reads from the replica on a miss and writes to the cache with a TTL (Time To Live). The eviction policy can be LRU (Least Recently Used) or LFU (Least Frequently Used); since link clicks concentrate heavily on a few popular links, both give a high hit rate.
Three details make the difference. The first is negative caching: codes that don't exist are cached too, with a short TTL. Otherwise a scanner trying codes at random reaches the database with every request. The second is expiration: a cache entry's TTL must not outlive the link's ExpiresAt, or an expired link keeps working from cache. The third is the hot key: the moment a newsletter goes out, a single link can get thousands of clicks per second; that link's single Redis key puts all the load on one node. The fix is a small in-memory cache of a few seconds in the redirect service instances; every instance answers the same popular link from its own memory. Going one step further, the redirect can be done at the edge of a CDN (Content Delivery Network), but then not every click reaches the server and analytics has to be collected from the edge's logs.
Deep Dive: Aliases, Expiration and Abuse
Custom aliases share the same Code column; the unique constraint catches collisions. But some words should never be handed out: names like api, admin, login that would clash with the application's own routes. I sent the prototype a few scenarios (I gave a past date to see an expired link; in a real API a past expiresAt should be a validation error too):
$ curl -s -X POST localhost:5392/api/links -H "Content-Type: application/json" -d '{"longUrl":"https://bookstore.example/sale","customAlias":"autumn-sale","expiresAt":"2026-11-01T00:00:00Z"}'
{"shortUrl":"http://localhost:5392/autumn-sale"}
$ curl -s -o /dev/null -w "%{http_code}" localhost:5392/autumn-sale
410
$ curl -s -X POST localhost:5392/api/links -H "Content-Type: application/json" -d '{"longUrl":"https://bookstore.example/x","customAlias":"admin"}'
{"error":"'admin' is not available."}
$ curl -s -X POST localhost:5392/api/links -H "Content-Type: application/json" -d '{"longUrl":"javascript:alert(1)"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"longUrl":["Must be an absolute http(s) URL."]}}
The expired link returns 410 Gone; the difference from 404 is that it says "it existed but is gone now". Deleting expired rows is a separate job: checking at redirect time ensures correctness, and a background job running at night cleans up the table. Dangerous schemes like javascript: are rejected at creation time.
Abuse is the most underestimated side of a URL shortener. Because a short link hides its destination, it's a favorite tool for phishing and malware distribution; once your domain lands on a blocklist, the legitimate links in your newsletter get blocked too. The defenses are layered: a per-user or per-API-key rate limit on link creation (as in the series' "Designing Rate Limiting: Token Bucket, Sliding Window and a Distributed Rate Limiter" post), checking the destination URL against a threat intelligence service at creation time and periodically, being able to disable a reported link quickly, and not allowing anonymous link creation at all. Since Bookstore's service is open only to its own marketing team, this risk is small; for a public service it's at the center of the design.
Trade-offs
| Decision | Our choice | Alternative and its cost |
|---|---|---|
| Code generation | Counter block + bijective mapping + base62 | Random code: unguessable but a collision check on every write; hash: same URL, same code, but collision handling |
| Redirect code | 302 | 301: less load, but click counting, editing and expiration are lost |
| Click counting | Queue + asynchronous aggregation | Synchronous UPDATE: exact, but redirect latency and write locks on hot rows |
| Read path | Redis cache-aside + negative cache + short in-memory cache | Redirect at the edge: fastest, but analytics depends on edge logs |
| Data layer | Single primary + read replica | Sharding: may be needed at 12 billion rows, but premature for 6 TB |
Common Pitfalls
Putting a 301 on a service that counts clicks
The browser remembers a 301, and the second click never reaches you. With analytics and expiration, use 302.
Assuming a truncated hash won't collide
Because of the birthday problem, collisions start even when only one in a thousand of the space is used. If you use a hash, design the collision path from the start.
Exposing consecutive codes
If the counter goes straight into base62, volume and order can all be read. Either pass it through a bijective mapping or use random codes.
A case-insensitive collation
Base62 is case-sensitive; if the database isn't, two different codes land on the same row.
Writing clicks to the link row
Running an UPDATE on every redirect chokes a read-heavy system on the locks of its most popular rows. Clicks are a separate stream.
A Small Experiment
★☆☆ Redo the capacity estimate with 1 billion new links a month and 20 years of retention. Are 7 characters still enough? What percentage of the space fills up?
★★☆ Add Redis-based cache-aside to the prototype: on a hit it shouldn't go to the replica, it should keep a 60-second negative cache for unknown codes, and the cache TTL shouldn't outlive ExpiresAt.
★★★ Design the analytics side that keeps hourly click counts per link: the event schema, the worker reading from the queue, preventing double counting under at-least-once delivery, and the table GET /api/links/{code}/stats reads from.
The URL shortener was a read-heavy system with a simple data model. In the next case study the write itself becomes a fan-out problem: we'll discuss how to deliver one user's post into thousands of followers' feeds, designing a news feed and timeline.