Design a URL Shortener (bit.ly) — The Complete System Design Walkthrough
A full system design walkthrough for a URL shortener like bit.ly: requirements, estimates, short-code generation, caching, and scaling to billions.
- Author
- Randhir Jassal
- Published
- Reading time
- 18 min read
- Views
- 2 views
"Design a URL shortener" is the question almost every system design interview opens with — and that's exactly why it's worth nailing. It looks trivial ("it's just a hashmap"), but the follow-ups are where candidates fall apart: How do you generate a short code with no collisions at a billion URLs? How do you serve 120,000 redirects a second without melting the database? 301 or 302? This walkthrough answers all of it, using the same repeatable framework you'd apply to any "Design X" problem.
A URL shortener takes a long URL like https://example.com/some/very/long/path?with=params and returns a short one like bit.ly/3xY9kQa. Click the short link, and the service redirects you to the original. Underneath, it's a deceptively simple key-value lookup wrapped in two hard problems: generating a unique short key and serving reads at massive scale. Let's design it end to end. (New to the method? Start with How to Crack Any System Design Interview — this post applies those exact eight steps.)
TL;DR — the design at a glance
| Concern | Decision |
|---|---|
| Short code | 7-char base62 → ~3.5 trillion combinations |
| Key generation | Key Generation Service (KGS) — pre-generate unique keys, hand them out |
| Storage | Key-value store (shortCode → longUrl), sharded by shortCode |
| Read path | Cache-first (Redis) — 100:1 read:write means the cache does the work |
| Redirect | 301 for performance, 302 if you need per-click analytics |
| Scale | ~1,200 writes/sec, ~120,000 redirects/sec, ~90 TB over 5 years |
- The whole problem is two things: generate a unique short key, and serve reads from cache.
- Base62 (7 chars) gives ~3.5 trillion keys — plenty for a decade of growth; 6 chars (~57 billion) is not.
- Don't hash-and-hope. A Key Generation Service pre-computes unique keys offline, so there are zero collisions at request time.
- It's read-heavy (~100:1). A billion redirects a day is a caching problem, not a database problem — the DB should rarely be touched on the hot path.
- 301 vs 302 is a real trade-off: 301 (permanent) lets the browser cache the redirect (fast, but you lose click analytics); 302 (temporary) routes every click through you (full analytics, more load).
- Shard by
shortCode— it's your primary key and it's uniformly distributed, so shards stay balanced. - A single global counter is a bottleneck and a single point of failure — hand out ranges, or use a KGS.
- ~1,200 writes/sec is easy; ~120,000 reads/sec is the number that shapes the architecture.
The one mental shift: a URL shortener isn't a "build a hashmap" problem — it's a "generate a unique key with no collisions, then serve a billion cached reads" problem. Get those two right and everything else is plumbing.
Step 1 — Requirements & scope
Functional (in scope):
- Given a long URL, return a unique short URL.
- Given a short URL, redirect to the original.
- Optional custom alias (
bit.ly/my-brand). - Optional expiry on links.
Parked (out of scope, say so): user accounts, detailed analytics dashboards, link editing, spam/malware scanning beyond a basic check.
Non-functional:
- Highly available — a redirect that 404s breaks every link ever shared. Availability > strong consistency here.
- Low-latency redirects — this is the hot path; target single-digit milliseconds.
- Read-heavy — roughly 100 reads per write. This one fact drives the whole design.
- Short, hard-to-guess codes — short for usability; not trivially enumerable for safety.
Step 2 — Back-of-envelope estimates
Assume 100 million new URLs/day and a 100:1 read:write ratio.
Writes: 100,000,000 / 86,400 s ≈ 1,160 writes/sec (~1.2k/sec)
Reads: 100× writes ≈ 116,000 reads/sec (~120k/sec)
Peak reads (≈2-3×) ≈ ~300k/sec
Storage (5 years):
100M/day × 365 × 5 ≈ ~180 billion URLs
× ~500 bytes/record ≈ ~90 TB
Two takeaways: writes are trivial (~1.2k/sec fits on a modest database), and the ~120k reads/sec is what forces caching and replication. The redirect path must almost never touch disk.
How long does the short code need to be? Using base62 (a–z, A–Z, 0–9):
62^6 ≈ 56.8 billion (too few — we need ~180 billion)
62^7 ≈ 3.5 trillion (plenty of headroom)
So 7 characters it is.
Step 3 — API design
POST /api/urls
body: { longUrl, customAlias?, expiresAt? }
-> { shortUrl } 201 Created
GET /{shortCode}
-> 301 (or 302) redirect to longUrl 404 if not found / expired
The redirect endpoint sits at the root path (bit.ly/{shortCode}) because the short URL is the request. Creating a URL is a normal authenticated write; redirecting is an anonymous, cache-friendly read.
Step 4 — Data model
One core mapping, best served by a key-value / wide-column store (the access pattern is a pure lookup by key — no joins, no scans):
urls
shortCode string (PK) -- e.g. "3xY9kQa"
longUrl string
createdAt timestamp
expiresAt timestamp (null)
userId string (null)
Why not a relational DB? You could — 180B rows is fine for a sharded SQL cluster too. But the access pattern is a single-key lookup, which is exactly what a key-value store (DynamoDB, Cassandra) does best at this scale, with easy horizontal scaling. Shard by shortCode — it's the lookup key and it's uniformly distributed, so no hot shards.
Step 5 — High-level architecture
WRITE (create short URL)
Client -> [ Load Balancer ] -> [ App servers ] --get key--> [ KGS ]
|
v
[ KV store: shortCode -> longUrl ]
READ (redirect) -- the hot path, ~120k/sec
Client -> [ Load Balancer ] -> [ App servers ]
|
[ Redis cache ] --hit (~90%+)--> 301/302
| miss
v
[ KV store ] --> populate cache --> redirect
App servers are stateless (scale horizontally behind the load balancer). The cache is the star of the read path — with an 80/20 access pattern, a Redis layer holding the hot links absorbs the vast majority of the 120k reads/sec, and the KV store only sees cache misses.
Step 6 — Deep-dive: generating the short code
This is the heart of the problem, and where interviews go deep. Three approaches, in increasing order of "what you'd actually ship."
Approach A — Hash the URL
Hash the long URL (MD5/SHA-256), then base62-encode and take the first 7 characters.
- Problem 1 — collisions. Truncating a hash to 7 chars will collide eventually. You'd have to check the DB on every write and re-hash (append a salt) on a clash — an extra read per write, and unbounded retries under load.
- Problem 2 — duplicates. The same long URL always hashes to the same code. Sometimes desirable, but it leaks that two users shortened the same link, and breaks custom expiry per link.
Workable, but the collision check makes writes stateful and slow.
Approach B — A counter + base62
Keep a global counter. Each new URL gets the next integer, which you base62-encode into the short code (so 1,000,000,000 becomes a compact 6-character string).
- Pro: zero collisions by construction — every number is unique.
- Con: a single counter is a bottleneck and a single point of failure, and sequential codes are guessable (I can walk
…a,…b,…cand scrape every link). - The fix for the bottleneck: don't use one counter — hand out ranges. A central coordinator (a DB sequence, or ZooKeeper) gives each app server a block of, say, 10,000 numbers; the server assigns them locally and only calls back when the block runs out. That removes per-write contention.
Base62, concretely. Encoding a number to base62 is just base conversion — repeatedly divide by 62 and map each remainder to a character in [0-9a-zA-Z]:
alphabet = "0123456789abcd...WXYZ" (62 characters)
encode(n):
s = ""
while n > 0:
s = alphabet[n % 62] + s
n = n / 62 # integer division
return s
# 100,000,000 -> a 5-character code; 7 chars covers up to 62^7 ≈ 3.5 trillion
Decoding runs the reverse. With a KGS (next) you never decode, because the generated key is the stored primary key — you look it up directly.
Approach C — Key Generation Service (KGS) ← what to ship
Flip the problem around: pre-generate unique 7-char keys offline, before any request arrives, and store them in a "keys" database split into unused and used.
- On a write, an app server just grabs an unused key and marks it used — an O(1) hand-out with no collision check and no hashing on the request path.
- To avoid every server hammering the KGS, each server checks out a block of keys into memory and serves from it.
- Uniqueness is guaranteed at generation time, so writes are simple and fast.
- Concurrency: marking a key "used" must be atomic so two servers never get the same key. Handing out blocks (not single keys) keeps that coordination rare.
KGS as a single point of failure: run a standby replica and replicate the keys DB. If a server dies holding a block of in-memory keys, you lose that block — harmless, since 3.5 trillion keys means throwing some away costs nothing.
Custom aliases & 301 vs 302
- Custom alias (
bit.ly/my-brand): check availability and insert directly; it bypasses the KGS. Enforce length/charset limits and reserve system words. - Redirect status: 301 (Moved Permanently) lets the browser cache the redirect — subsequent clicks skip your server entirely (fast, cheap, but you lose per-click analytics and can't change the destination). 302 (Found / temporary) routes every click through you — full analytics and a changeable target, at the cost of more traffic. Pick per product goal; analytics-driven shorteners lean 302.
Step 7 — Bottlenecks & scaling
- Cache the hot path. Link popularity is heavily skewed (a few links get most clicks), so an LRU Redis cache with a high hit rate keeps the KV store nearly idle on reads. This is the scaling lever.
- Read replicas / a distributed KV store for cache misses; shard by
shortCode. - CDN / edge for globally shared links — serve the redirect close to the user.
- KGS availability — standby + replicated keys DB (above).
- Expiry & cleanup — a background job (or a TTL in the KV store) purges expired links so storage doesn't grow forever; return their keys to the pool if you like.
- Abuse — rate-limit creation per user/IP, and screen destination URLs against a malware/phishing list.
Step 8 — Trade-offs & wrap-up
- KGS vs counter vs hash: KGS gives collision-free, fast, unguessable-enough keys at the cost of a small extra service to run — worth it. Counter-with-ranges is a fine simpler alternative; pure hashing is the one to avoid.
- 301 vs 302: performance vs analytics. Know why you picked one.
- KV vs SQL: both work; KV wins on operational simplicity at this scale and access pattern.
- Consistency: eventual consistency is fine — a newly created link being readable a few hundred milliseconds later is acceptable; a redirect being fast and available is not negotiable.
Tracking clicks without slowing the redirect
If click analytics matter (and you chose 302 so every click reaches you), never write to an analytics database on the redirect path — that puts a slow write in front of a latency-critical read. Instead, fire-and-forget: the app server emits a lightweight click event onto a message queue and returns the redirect immediately. Workers aggregate those events downstream.
GET /{shortCode}
-> look up longUrl (cache) ~1-2ms
-> emit click event to Kafka fire-and-forget { shortCode, ts, referrer, geo }
-> 302 redirect returned immediately
|
[ Kafka ] -> [ workers ] -> [ analytics store / counters ]
This is the same "enqueue and return" discipline that keeps any write-heavy side channel off a hot read path — see We Replaced REST with Kafka.
Capacity, in one place
| Metric | Estimate |
|---|---|
| New URLs/day | 100 million |
| Writes/sec | ~1,200 |
| Reads (redirects)/sec | ~120,000 (peak ~300k) |
| Read:write ratio | ~100:1 |
| Short code | 7 chars, base62 (~3.5 trillion) |
| Storage (5 yr) | ~90 TB |
The design checklist
- Scoped to shorten + redirect (+ optional custom alias & expiry).
- Estimated: ~1.2k writes/sec, ~120k reads/sec, ~90 TB / 5 yr.
- Short code: 7-char base62 (~3.5 trillion).
- Key generation: KGS (pre-generated, block-checkout, standby replica).
- Storage: key-value, sharded by
shortCode. - Read path is cache-first (Redis, LRU) — DB sees only misses.
- Chose 301 vs 302 deliberately (performance vs analytics).
- Covered expiry cleanup, rate limiting, and malicious-URL screening.
In production at Mattrx
Mattrx puts a Share button on every dashboard and PDF report: click it and the app mints a short link like mtrx.co/r/aZ4kP2q that redirects to the shared resource. The first version embedded the report's Azure SQL primary key — a 36-char NEWSEQUENTIALID() GUID — directly in the URL, so links were long, ugly, and (because sequential GUIDs are partly ordered) enumerable, and every open ran a SELECT against Azure SQL that pushed redirect p95 to ~180 ms. We rebuilt it as exactly the design above: a Key Generation Service hands out pre-generated 7-char base62 codes by the block, and a cache-first minimal-API endpoint resolves them from Redis before it ever touches SQL.
| Metric | Before | After |
|---|---|---|
| Share-link format | 36-char GUID (report's sequential PK) | 7-char base62 code |
| Guessability | Enumerable (sequential GUID) | Crypto-random, ~3.5T keyspace |
| Redirect data source | Azure SQL on every open | Redis cache-first, SQL only on a miss |
| Cache hit rate | none (no cache) | ~97% |
| Redirect p95 | ~180 ms | ~5 ms |
| Code hand-out | n/a | O(1) block checkout, zero collision checks |
The redirect is now a single Redis round-trip for ~97% of opens, the links leak nothing about internal identifiers, and Azure SQL sees only the cold-cache misses.
The architecture at Mattrx
React dashboard ── Share ──▶ [ Azure App Service · .NET 9 API ]
│ mint short code
▼
[ KeyGenerationService ] ◀─ block checkout ─ [ Azure SQL · ShortCodePool ]
│ store mapping
▼
[ Azure SQL · ShareLinks ]
Opening mtrx.co/r/{code}:
Browser ──▶ [ .NET 9 API ] ──▶ [ Azure Cache for Redis ] ─hit ~97%─▶ 302 redirect
│ miss
▼
[ Azure SQL ] ─▶ populate Redis ─▶ 302
Production implementation (Mattrx)
Here is the core of it as shipped in the Mattrx report-sharing service on ASP.NET Core / .NET 9: the KeyGenerationService that checks out blocks of pre-minted codes and marks them used atomically, and the cache-first minimal-API redirect endpoint that resolves mtrx.co/r/{code} from Redis before it ever touches Azure SQL.
// ── Program.cs ──────────────────────────────────────────────────────────
using System.Data;
using Mattrx.Sharing;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Caching.Distributed;
var builder = WebApplication.CreateBuilder(args);
// IDistributedCache backed by the shared Redis (StackExchange.Redis underneath).
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "mtrx:share:";
});
builder.Services.Configure<KeyGenerationOptions>(builder.Configuration.GetSection("KeyGeneration"));
builder.Services.AddSingleton(new SqlConnectionFactory(
builder.Configuration.GetConnectionString("AzureSql")!));
builder.Services.AddSingleton<KeyGenerationService>();
var app = builder.Build();
// Share button on a dashboard / PDF report -> mint a short link.
app.MapPost("/api/reports/{reportId:guid}/share", async (
Guid reportId,
KeyGenerationService keys,
SqlConnectionFactory db,
CancellationToken ct) =>
{
var code = await keys.NextCodeAsync(ct); // O(1), no hashing, no collision check
var target = $"https://app.mattrx.co/reports/{reportId}";
await using var connection = await db.OpenAsync(ct);
await using var insert = connection.CreateCommand();
insert.CommandText =
"""
INSERT INTO dbo.ShareLinks (Code, ReportId, TargetUrl, CreatedAtUtc, ExpiresAtUtc)
VALUES (@code, @reportId, @target, SYSUTCDATETIME(), DATEADD(day, 30, SYSUTCDATETIME()));
""";
insert.Parameters.Add(new SqlParameter("@code", SqlDbType.Char, 7) { Value = code });
insert.Parameters.Add(new SqlParameter("@reportId", SqlDbType.UniqueIdentifier) { Value = reportId });
insert.Parameters.Add(new SqlParameter("@target", SqlDbType.NVarChar, 2048) { Value = target });
await insert.ExecuteNonQueryAsync(ct);
return Results.Ok(new { shortUrl = $"https://mtrx.co/r/{code}" });
});
// The hot path: resolve mtrx.co/r/{code} -> report URL. Cache-first.
// The length(7) constraint rejects malformed paths before any I/O.
var shareLinkTtl = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24)
};
app.MapGet("/r/{code:length(7)}", async (
string code,
IDistributedCache cache,
SqlConnectionFactory db,
CancellationToken ct) =>
{
var cacheKey = $"r:{code}";
// 1) Redis first — ~97% of opens are served entirely from here.
var target = await cache.GetStringAsync(cacheKey, ct);
if (target is not null)
return Results.Redirect(target, permanent: false); // 302
// 2) Miss -> Azure SQL. The expiry check keeps revoked links from resolving.
target = await ResolveFromSqlAsync(db, code, ct);
if (target is null)
return Results.NotFound();
// 3) Populate the cache so the next open never touches SQL.
await cache.SetStringAsync(cacheKey, target, shareLinkTtl, ct);
return Results.Redirect(target, permanent: false); // 302
});
app.Run();
static async Task<string?> ResolveFromSqlAsync(
SqlConnectionFactory db, string code, CancellationToken ct)
{
await using var connection = await db.OpenAsync(ct);
await using var command = connection.CreateCommand();
command.CommandText =
"""
SELECT TargetUrl
FROM dbo.ShareLinks
WHERE Code = @code
AND (ExpiresAtUtc IS NULL OR ExpiresAtUtc > SYSUTCDATETIME());
""";
command.Parameters.Add(new SqlParameter("@code", SqlDbType.Char, 7) { Value = code });
return await command.ExecuteScalarAsync(ct) as string;
}
// ── KeyGenerationService.cs ─────────────────────────────────────────────
namespace Mattrx.Sharing;
using System.Collections.Concurrent;
using System.Data;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
public sealed class KeyGenerationOptions
{
// How many codes to claim from Azure SQL per checkout.
public int BlockSize { get; init; } = 1_000;
// Refill the in-memory buffer once it drops to this level.
public int RefillThreshold { get; init; } = 200;
}
// Hands out pre-generated, globally-unique 7-char base62 codes. Codes are
// minted offline into dbo.ShortCodePool; this service checks out a block at
// a time and serves it from memory, so the Share write path never hashes and
// never runs a collision check.
public sealed class KeyGenerationService(
SqlConnectionFactory connectionFactory,
IOptions<KeyGenerationOptions> options,
ILogger<KeyGenerationService> logger)
{
private readonly KeyGenerationOptions _options = options.Value;
private readonly ConcurrentQueue<string> _buffer = new();
private readonly SemaphoreSlim _refillGate = new(1, 1);
public async ValueTask<string> NextCodeAsync(CancellationToken ct)
{
// Top up while the buffer is still non-empty so a single slow
// checkout never stalls a burst of Share clicks.
if (_buffer.Count <= _options.RefillThreshold)
await RefillAsync(ct);
if (_buffer.TryDequeue(out var code))
return code;
// Buffer drained under a burst — refill once more, then fail loudly.
await RefillAsync(ct);
if (_buffer.TryDequeue(out code))
return code;
throw new InvalidOperationException(
"Short-code pool is exhausted. Mint a new batch into dbo.ShortCodePool.");
}
private async Task RefillAsync(CancellationToken ct)
{
await _refillGate.WaitAsync(ct);
try
{
// Another caller may have refilled while we waited on the gate.
if (_buffer.Count > _options.RefillThreshold)
return;
await using var connection = await connectionFactory.OpenAsync(ct);
await using var command = connection.CreateCommand();
// Atomic block checkout: claim up to @blockSize unused codes and
// mark them used in a single statement. UPDLOCK reserves the rows;
// READPAST lets concurrent checkouts skip each other's locked rows
// instead of blocking, so two app servers can never get the same
// code — no distributed lock required.
command.CommandText =
"""
UPDATE TOP (@blockSize) dbo.ShortCodePool WITH (UPDLOCK, READPAST, ROWLOCK)
SET IsUsed = 1, CheckedOutAtUtc = SYSUTCDATETIME()
OUTPUT inserted.Code
WHERE IsUsed = 0;
""";
command.Parameters.Add(new SqlParameter("@blockSize", SqlDbType.Int)
{
Value = _options.BlockSize
});
var claimed = 0;
await using var reader = await command.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
_buffer.Enqueue(reader.GetString(0));
claimed++;
}
if (claimed == 0)
logger.LogError("dbo.ShortCodePool is empty; mint a new batch of codes.");
else
logger.LogDebug("Checked out {Claimed} short codes into the buffer.", claimed);
}
finally
{
_refillGate.Release();
}
}
}
// ── Infrastructure ──────────────────────────────────────────────────────
namespace Mattrx.Sharing;
using System.Security.Cryptography;
using Microsoft.Data.SqlClient;
public sealed class SqlConnectionFactory(string connectionString)
{
public async Task<SqlConnection> OpenAsync(CancellationToken ct)
{
var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
return connection;
}
}
// The offline batch job that keeps dbo.ShortCodePool topped up calls NewCode().
// Crypto-random (not a sequence) is what makes a code unguessable — nobody can
// walk r/0000001, r/0000002, ... to enumerate another tenant's reports.
public static class Base62
{
private const string Alphabet =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
public static string NewCode(int length = 7) =>
string.Create(length, Alphabet, static (span, alphabet) =>
{
for (var i = 0; i < span.Length; i++)
span[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
});
}
The UPDATE TOP (@blockSize) ... OUTPUT inserted.Code WITH (UPDLOCK, READPAST) statement is the entire concurrency story: many app servers can check out blocks concurrently and are guaranteed disjoint codes with no distributed lock, and each server then serves its block from an in-memory queue so the write path stays O(1). On the read path the endpoint returns a 302, not a 301, on purpose — Mattrx counts every report open and lets owners revoke a link, both of which require every click to reach the server, which is exactly why the redirect had to become a single Redis round-trip.
The honest stuff: when this is overkill (and other caveats)
- A small internal shortener needs none of this. For a company-internal tool doing thousands of links, a single Postgres table with an auto-increment ID base62-encoded is the whole system. Don't build a KGS for that.
- 301 kills your analytics. If click tracking is a product feature, you must use 302 (or a tracking hop) — the browser cache from a 301 means you'll never see repeat clicks. Be explicit about the trade.
- Sequential codes are a data leak. If you go with a counter, encode/scramble it so codes aren't trivially enumerable; otherwise anyone can scrape every link you've ever made.
- Expiry is not optional at scale. Without a cleanup policy, storage grows forever and dead links linger. Decide TTL behavior up front.
- You're an open-redirect vector. Shorteners are abused to disguise phishing links. Screen destinations and rate-limit creation, or you become a spammer's tool.
- Custom aliases collide with your namespace. Reserve system paths (
/api,/login) and enforce a charset, or a user will claimbit.ly/admin. - The KGS keys DB can look scary-large — but you never pre-generate all 3.5 trillion keys; generate in batches ahead of demand and top up. Storing only used keys plus a modest buffer keeps it small.
The model to carry forward
A URL shortener is a key-generation problem stapled to a caching problem. Everything hard about it lives in those two places: making a short code that's unique without a collision check (solved cleanly by pre-generating keys), and serving a hundred thousand redirects a second without touching disk (solved by caching the skewed-popular links). Get comfortable explaining why the KGS beats hashing and why the read path is cache-first, and you've answered not just this question but the shape of a dozen others.
Three habits this problem teaches:
- Let the read:write ratio pick your architecture. 100:1 means "cache," full stop — say it early.
- Generate uniqueness ahead of time. Pre-computing keys turns a collision problem into an O(1) hand-out.
- Name the redirect trade-off. 301 vs 302 is a small detail that signals you think about product goals, not just boxes.
Further reading
- How to Crack Any System Design Interview: A Repeatable Framework
- Outbox Pattern — A Complete Guide with Order Processing Example
- We Replaced REST with Kafka and Cut Failures by 90%
Prepping for a system design round and want this walked through live — or the next "Design X" broken down the same way? I'm always happy to help; reach me at randhir.jassal@gmail.com.
Get the next issue
A short, curated email with the newest posts and questions.