A weekly series where we break down real systems — from the business problem, to the architecture, to the exact data structure that makes it work.
The Problem
Services like bit.ly or TinyURL take a long URL and return a short one that redirects back to the original. Simple to describe, but designing it properly at scale surfaces real engineering decisions — which is exactly why it's one of the most commonly discussed system design problems.
Let's design it the way we'd actually approach it for a client.
Step 1: Define the Requirements
Functional requirements:
- Given a long URL, generate a unique short URL
- Given a short URL, redirect to the original long URL
- Links should not collide — two different long URLs should never map to the same short code
- (Optional) Support custom aliases and link expiration
Non-functional requirements:
- High availability — a link redirect service going down is highly visible and damaging
- Low latency — redirects need to feel instant
- Scale — a real-world service needs to handle billions of URLs and heavy read traffic (redirects vastly outnumber creations)
Notice we haven't written a line of code yet. This is the part that separates a considered system from a guess — and it's the part we spend real time on with clients before touching implementation.
Step 2: High-Level Design
At a high level, we need:
- A write path — takes a long URL, generates a short code, stores the mapping
- A read path — takes a short code, looks up the long URL, issues a redirect
- A datastore — a simple key-value store works well here (short code → long URL), since lookups are always by exact key
Because reads dominate writes by a huge margin in a real link shortener, a caching layer (like Redis) in front of the database is a natural addition — most popular links get looked up repeatedly, and serving those from memory instead of hitting the database every time is a meaningful performance win.
Step 3: The Data Structure Underneath — Hashing & Base62 Encoding
This is the core algorithmic decision, and it's worth being precise about what we're actually doing.
We are not compressing the URL. Compression is reversible and complex; what we actually want is a short, unique identifier. The clean way to get that:
- Every new URL gets a unique, auto-incrementing numeric ID (e.g., from the database, or a distributed ID generator at scale)
- That numeric ID is encoded into Base62 — using the 62 characters A-Z, a-z, 0-9 — which turns a large number into a short string
Why Base62 specifically? Because it's URL-safe (no special characters to escape) and dense — a 7-character Base62 string can represent numbers up to 62^7, which is over 3.5 trillion unique IDs. That's more than enough headroom for a real product.
Python Implementation
BASE62_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
def encode_base62(num: int) -> str:
"""Convert a unique numeric ID into a short Base62 string."""
if num == 0:
return BASE62_CHARS[0]
result = []
base = len(BASE62_CHARS)
while num > 0:
num, remainder = divmod(num, base)
result.append(BASE62_CHARS[remainder])
return ''.join(reversed(result))
def decode_base62(short_code: str) -> int:
"""Convert a Base62 short code back into its original numeric ID."""
base = len(BASE62_CHARS)
num = 0
for char in short_code:
num = num * base + BASE62_CHARS.index(char)
return num
# Example usage
url_id = 125_000_000
short_code = encode_base62(url_id)
print(short_code) # e.g. "8M0kQ"
print(decode_base62(short_code)) # 125000000Because every ID is unique by construction (it comes from an auto-incrementing counter or distributed ID generator), collisions are structurally impossible — we're not hashing the URL content and hoping for no clashes, we're encoding a guaranteed-unique number. This is a subtle but important distinction from naive approaches that hash the URL string itself and then have to handle collision retries.
Step 4: Trade-offs at Scale
This is the part that separates a whiteboard answer from something we'd actually ship for a client.
The bottleneck: A single auto-incrementing counter becomes a single point of contention once you have multiple API servers trying to generate IDs simultaneously.
The fix: At real scale, you'd replace the simple auto-increment with a distributed unique ID generator — something like Twitter's Snowflake algorithm, which generates unique IDs made up of a timestamp, a machine ID, and a sequence number, so multiple servers can generate IDs independently without coordinating on every single request.
Other real considerations we'd flag to a client:
- Custom aliases need a separate uniqueness check against the datastore before insert
- Analytics (click counts per link) benefit from an async write path — don't block the redirect on writing analytics data
- Link expiration needs a cleanup strategy — a background job (or TTL if using something like Redis) rather than checking on every read
Why We Build This Way
The algorithm — Base62 encoding — is genuinely the easy part. The value we bring on a project like this is in the requirements gathering, the read/write path design, the caching strategy, and knowing which trade-offs actually matter at your specific scale versus which ones are premature optimization.
That's the thinking we try to make visible in this series — not just "here's some code," but here's how we'd actually reason through it with you.
This is Week 1 of Brainy Blueprints, our weekly series breaking down real systems from business problem to production code. Next week: the LRU Cache, and how it powers API response caching.
📩 Want to talk through a system design problem for your product? [email protected]