How to Design a URL Shortener
Designing a URL shortener sounds simple until the simple version meets real traffic. This post starts with one table, evolves to Redis, and shows how to generate short codes without collisions or needless complexity.
How to Design a URL Shortener
Designing a URL shortener sounds simple right until you try to make the simple version survive real traffic.
That is exactly why I like this problem.
It starts with something almost boring: take a long URL, store it somewhere, return a shorter one. But if you keep pulling on that thread, you quickly run into the questions that make system design interesting. How do you generate short codes cleanly? What happens when two codes collide? Why does a tiny redirect service suddenly need Redis? What should stay in the database, and what deserves to live in RAM?
In this post, I want to build the system the way a real engineer would. We will begin with one table and one straightforward redirect flow. Then we will let the pain points show up naturally and evolve the design only when the traffic pattern gives us a reason.
By the end, you should not just know the final architecture. You should have a much better grip on how to think your way there.
IMAGE PLACEHOLDER: Featured hero illustration for the article. Show a clean, product-style visual of a browser, a URL shortener service, Redis, and a database, with the title "How to Design a URL Shortener" integrated into the composition.
URL shortener requirements
Let us begin with the product itself before we rush into architecture.
At its core, a URL shortener does two things:
- It accepts a long URL and returns a short URL.
- It takes a short URL, resolves the original destination, and redirects the user.
That means we have two main flows:
- the write path:
POST /shorten - the read path:
GET /:shortCode
The write path is where we create a new mapping. The read path is where the real load usually lives.
One link may be created once and clicked hundreds, thousands, or millions of times. That asymmetry becomes the most important architectural clue in the whole system.
For this post, I am going to optimize for these goals:
- short links should resolve quickly
- the system should be easy to reason about
- uniqueness should be guaranteed cleanly
- the design should scale gradually instead of starting overengineered
The simplest design that actually works
Before talking about Redis, caching, or distributed ID generation, let us build the first useful version.
We can do that with:
- one backend service
- one relational database
- one table storing the mapping between short code and long URL
That alone is enough to launch a real feature.
Database schema for a URL shortener
A clean first schema might look like this:
CREATE TABLE short_urls (
id BIGSERIAL PRIMARY KEY,
long_url TEXT NOT NULL,
short_code VARCHAR(16) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NULL
);
There are only a few important fields here:
idgives us a durable internal identitylong_urlstores the original destinationshort_codeis what users actually see and shareexpires_atis optional, but useful if we ever want temporary links
If you are new to system design, this is one of those moments where I think it helps to pause and appreciate something simple: this table is already the heart of the product. Everything we add later exists to make this basic model faster, safer, or more scalable.
IMAGE PLACEHOLDER: Simple database-first architecture diagram. Show Browser -> URL Shortener API -> Postgres. Also show both flows: create short URL and redirect using a short code lookup.
Create short URL flow
The write path is conceptually simple:
- validate the long URL
- generate a candidate short code
- save the mapping in the database
- return the final short URL
Here is the naive pseudocode:
async function createShortUrl(longUrl: string) {
validate(longUrl);
const shortCode = generateShortCode(longUrl);
await db.insert("short_urls", {
long_url: longUrl,
short_code: shortCode,
});
return `https://hs.io/${shortCode}`;
}
At first glance, that looks almost too easy.
And that is exactly the trap.
The hard part is not the insert. The hard part is hidden inside generateShortCode.
IMAGE PLACEHOLDER: Sequence diagram for the create flow. Show Client -> API -> Code Generator -> Database. Highlight the decision point where a generated code may collide and needs another attempt.
The first instinct: hash the URL
The most natural first idea is to hash the original URL and take a short slice of the result.
Something like:
shortCode = sha256(longUrl).slice(0, 7);
I actually like this as a teaching starting point because it feels elegant:
- same input gives same output
- easy to implement
- no extra counter or sequence needed
- deterministic behavior feels clean
If you are building a toy version, this works surprisingly well.
But if we are trying to reason like engineers instead of just getting lucky, we need to notice the limitations early.
Why hashing alone is not enough
1. Truncated hashes can still collide
The full hash may be strong, but the moment you take only a small prefix, you are back in probability land.
That means two different long URLs can eventually produce the same visible short code.
Rare is not the same as impossible.
2. The same URL always maps to the same code
Sometimes that is nice. Sometimes it quietly forces a product decision you may not want.
For example:
- what if the same destination should have different campaign links?
- what if two users shorten the same URL but want separate analytics?
- what if one link expires and another should stay permanent?
A direct hash of the long URL makes the system more rigid than it first appears.
3. Collision recovery becomes awkward
The moment a collision happens, your "clean" solution stops looking so clean.
Do you:
- append a salt?
- rehash with a retry counter?
- increase code length?
- mix in the current timestamp?
All of those are workable. None of them feel as elegant as the original one-liner.
That is a good engineering lesson on its own: sometimes a neat-looking solution only stays neat while nothing goes wrong.
Better short code generation strategies
If we are thinking in production terms, I see four practical strategies.
Random Base62 strings
Generate a random string from:
[a-z][A-Z][0-9]
This is common because the code space is large, the output stays compact, and the implementation is simple.
The standard production pattern is:
- generate a random code
- try to insert it
- rely on the database unique constraint
- retry if there is a collision
async function createShortUrl(longUrl: string) {
validate(longUrl);
for (let attempt = 0; attempt < 5; attempt++) {
const shortCode = randomBase62(7);
try {
await db.insert("short_urls", {
long_url: longUrl,
short_code: shortCode,
});
return `https://hs.io/${shortCode}`;
} catch (err) {
if (!isUniqueViolation(err)) throw err;
}
}
throw new Error("Could not generate a unique code");
}
This approach is probabilistic, but importantly, it is not sloppy. The database is still the final authority on uniqueness.
Counter + Base62 encoding
This is my favorite "boring but strong" option.
The flow is:
- get a unique numeric ID
- encode that ID in Base62
- use the encoded result as the short code
If the underlying ID is unique, the short code is unique.
That gives you:
- predictable uniqueness
- compact codes
- easy reasoning
- fewer retries than random generation
The only real tradeoff is that sequential growth can become slightly guessable unless you add obfuscation.
Hash of the URL
This is still useful to discuss because many people start here. I would keep it as a learning step or prototype strategy, but I would not choose it as my final answer unless the product explicitly wanted deterministic one-link-per-URL behavior.
Snowflake-style distributed IDs
This is more interesting once the system grows beyond a single write source.
Instead of leaning on one database sequence, you use a distributed ID generator and then Base62-encode the result.
This is powerful, but I would not start here. It solves a real problem, just not the first problem most teams have.
IMAGE PLACEHOLDER: Comparison visual for short-code generation strategies. Use four side-by-side panels for hash(long_url), random Base62, counter + Base62, and Snowflake-style IDs. Show how each works, collision risk, and practical tradeoffs.
Read path: redirect the short URL
Now let us move to the part of the system that actually gets hammered.
The redirect flow looks like this:
- user opens
https://hs.io/abc123 - service extracts
abc123 - service looks it up
- service returns an HTTP redirect to the long URL
Pseudocode:
async function resolve(shortCode: string) {
const row = await db.findOne("short_urls", { short_code: shortCode });
if (!row) {
return notFound();
}
if (row.expires_at && row.expires_at < new Date()) {
return gone();
}
return redirect(row.long_url);
}
Functionally, this is easier than the write path.
Architecturally, it matters more.
Because every click goes through it.
IMAGE PLACEHOLDER: Redirect flow diagram. Show a browser requesting a short URL, the service resolving the code, and returning a redirect to the original destination. Add a note that this path is the latency-sensitive hot path.
Where the simple design starts hurting
If every redirect does a fresh database lookup, the system stays correct, but it starts paying an unnecessary price.
Imagine one short URL suddenly going viral.
You now have the same short code getting looked up over and over again:
- same key
- same destination
- same database query pattern
That is a huge hint that memory can help.
This is not a case of adding Redis because "scalable systems use Redis."
This is a case of adding Redis because the access pattern is begging for it.
Scaling a URL shortener with Redis
The simplest useful caching approach is read-through caching.
The redirect service:
- checks Redis for the short-code mapping
- redirects immediately on a cache hit
- falls back to the database on a miss
- writes the result back to Redis for future requests
async function resolve(shortCode: string) {
const cached = await redis.get(`short:${shortCode}`);
if (cached) {
return redirect(cached);
}
const row = await db.findOne("short_urls", { short_code: shortCode });
if (!row) {
return notFound();
}
await redis.set(`short:${shortCode}`, row.long_url, { EX: 3600 });
return redirect(row.long_url);
}
This is the point where "keep the hash in RAM" becomes a practical system design decision rather than just a nice phrase.
What we are really keeping in RAM is the hot lookup result:
- short code
- destination URL
And that makes sense because the redirect path is read-heavy and repetitive.
IMAGE PLACEHOLDER: Redis-backed architecture diagram. Show Browser -> API -> Redis first, then fallback to Postgres on cache miss. Include clear labels for cache hit and cache miss.
Why Redis helps so much here
The URL shortener problem has one of the most cache-friendly patterns you can ask for.
If a link is popular, the exact same short code gets requested again and again.
Without Redis:
- the database absorbs every read
- latency is tied directly to DB response time
- hot links create concentrated pressure on the primary data store
With Redis:
- the first request may hit the database
- later requests stay in memory
- the hottest links become cheap to serve
This is what people mean when they talk about hot keys.
One tiny piece of data can become disproportionately important because so much traffic converges on it.
IMAGE PLACEHOLDER: Hot-key visualization. Show many users hitting one popular short code. In one half, all traffic goes to the database. In the other half, most traffic is absorbed by Redis.
Redis is not the source of truth
This distinction matters.
Redis is here for speed, not correctness.
The database remains responsible for:
- durable storage
- uniqueness guarantees
- expiry metadata
- long-term recovery
If Redis disappears, the system should slow down, not break.
That is the architecture you want.
How to handle collisions cleanly
If you choose random Base62, collision handling should be explicit.
The wrong mental model is:
- check whether the code exists
- if it does not, insert it
That introduces a race between the check and the insert.
The better model is:
- try the insert directly
- let the unique constraint decide
- retry only on conflict
That keeps the logic atomic and honest.
The flow becomes:
- generate candidate code
- attempt insert
- if insert succeeds, return success
- if the database reports a uniqueness conflict, generate another code
- after a small retry budget, fail safely and alert
That is not just cleaner. It is the kind of design that stays correct under concurrency.
IMAGE PLACEHOLDER: Collision retry flowchart. Show generate candidate code -> attempt insert -> success path and collision path. Include a retry loop and a final failure fallback after max attempts.
Should Redis generate unique IDs too?
It can.
For example, you could use:
INCR url:id
and then Base62-encode the result.
This works because Redis increments are atomic and fast.
But I would separate two different uses of Redis in my head:
- Redis as a cache for redirect lookups
- Redis as an ID generator
Both are valid. They just solve different problems.
If I were building the first serious version of this product, I would probably keep ID generation in the database or a database-backed sequence and use Redis only for caching. That keeps the architecture easy to explain and easy to trust.
Product questions hidden inside the system design
A nice thing about the URL shortener problem is that infrastructure and product decisions are tightly connected.
Should the same long URL always return the same short URL?
You can do that, but it is not always the right call.
If you deduplicate aggressively, you lose flexibility for:
- campaign-specific links
- per-user ownership
- custom expiry
- link-level analytics
I would not force deterministic deduplication unless the product specifically benefits from it.
Should analytics happen on the redirect path?
I would be careful here.
The redirect path is your hottest user-facing path. If analytics writes or event processing slow it down, users feel that immediately.
A better approach is usually:
- redirect first
- capture analytics asynchronously
Should links expire?
That depends on the product.
But adding expires_at to the schema early is cheap, and it gives you space to evolve.
Final production-ready architecture
If I were drawing the version I would feel good shipping after the first round of growth, it would look like this:
- stateless URL shortener service
- Postgres as the source of truth
- Redis for hot redirect lookups
- unique constraint on
short_code - either
random Base62 + retryorID + Base62 - asynchronous analytics pipeline
- background cleanup for expired links
What I like about this design is that it still feels like a grown-up version of the simple one.
We did not throw away the core model. We just respected the traffic pattern.
IMAGE PLACEHOLDER: Final production architecture diagram. Show browser, load balancer or edge, stateless API service, Redis, Postgres, optional analytics pipeline, and a background worker for expiry cleanup.
What I would choose in production
If I had to choose one design today, I would probably do this:
- store mappings in Postgres
- generate unique IDs with a database sequence
- Base62-encode the ID into the visible short code
- use Redis as a read-through cache for hot redirects
- keep analytics asynchronous
Why this design?
Because it is strong without being dramatic.
It starts from a system a junior engineer can understand in one sitting. It scales in the direction the traffic naturally pushes it. And every component has a concrete reason to exist.
That is the kind of system design I trust the most.
Final thoughts
The URL shortener problem is not interesting because the product is complicated.
It is interesting because the product is small enough that every tradeoff becomes visible.
You can see exactly when a neat hashing idea stops being enough. You can see why a hot read path deserves Redis. You can see how uniqueness, latency, and product flexibility all tug on the same design.
If you are learning system design, this is the mindset I hope you carry forward:
- start with the smallest version that works
- let the pressure points reveal themselves
- add complexity only when the system earns it
That is how many real systems grow. And honestly, that is also how system design stops feeling abstract.