← Back to System Design
14 min read

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.

Placeholder featured image for the URL shortener system design article
System DesignURL ShortenerRedisDatabasesBackend

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:

  1. It accepts a long URL and returns a short URL.
  2. 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:

  • id gives us a durable internal identity
  • long_url stores the original destination
  • short_code is what users actually see and share
  • expires_at is 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:

  1. validate the long URL
  2. generate a candidate short code
  3. save the mapping in the database
  4. 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:

  1. get a unique numeric ID
  2. encode that ID in Base62
  3. 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:

  1. user opens https://hs.io/abc123
  2. service extracts abc123
  3. service looks it up
  4. 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:

  1. checks Redis for the short-code mapping
  2. redirects immediately on a cache hit
  3. falls back to the database on a miss
  4. 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:

  1. generate candidate code
  2. attempt insert
  3. if insert succeeds, return success
  4. if the database reports a uniqueness conflict, generate another code
  5. 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 + retry or ID + 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.

← Back to all system design posts