beiryu
  • Projects
  • Blog
  • About

© 2026 Khanh Dinh. I build software and write about the journey.

  • GitHub
  • LinkedIn
  • X
September 12, 2025 · 0 views

System design shorter URL service

Beyond 'hash the URL and redirect': how to generate short codes that never collide and cannot be guessed, why 302 usually beats 301, keeping redirects fast under viral traffic, analytics without hot writes, and the abuse and expiry problems that bite in production.

  • Architecture
  • Backend
  • Caching

The URL shortener is the "hello world" of system design interviews, so most write-ups stop at "hash the URL, store it, redirect". The interesting part is everything after that: how the short code is generated without collisions or guessable IDs, why the choice between 301 and 302 is a business decision, how to survive a link going viral, and what happens when someone shortens a phishing page.

This post covers the basics quickly, then spends its time on those decisions and ends with the design I would actually build.

Requirements#

Functional

  • Given a long URL, return a short one like sho.rt/aZ3kQ9x.
  • Visiting the short URL redirects to the original.
  • Optional: custom aliases, expiry dates, click analytics.

Non-functional

  • Redirects must be fast and highly available. A broken short link breaks every place it was shared.
  • Short codes must not be guessable, so private links cannot be enumerated.
  • Reads vastly outnumber writes.

Back of the envelope#

Assume 100 million new links per month and 100 redirects per link created:

QuantityEstimate
Writes100M / month ≈ 40 per second (peaks ~10× higher)
Reads≈ 4,000 redirects per second on average
Links after 5 years≈ 6 billion
Storage at ~500 bytes per link≈ 3 TB
Code space, 7 chars of base6262⁷ ≈ 3.5 trillion

Two conclusions shape everything else: the write path is small and the read path is what needs engineering, and a 7-character code leaves plenty of room. Six billion links fill under 0.2% of the space.

The data model#

The core is a single lookup by key, which almost any database handles well:

CREATE TABLE links (
  code        VARCHAR(16) PRIMARY KEY,   -- the short code
  long_url    TEXT        NOT NULL,
  owner_id    BIGINT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at  TIMESTAMPTZ,
  disabled    BOOLEAN     NOT NULL DEFAULT false
);

PostgreSQL is fine for years at this scale, especially with a cache in front. A key-value store like DynamoDB or Cassandra fits the access pattern exactly and scales without sharding work, at the cost of flexible querying for the admin side.

Decision 1: generating the short code#

This is where most designs go wrong. There are four common approaches.

Hash the URL and truncate#

Take MD5 or SHA-256 of the long URL, base62-encode it, keep the first 7 characters.

  • Pro: the same URL always gets the same code, which deduplicates for free.
  • Con: truncating a hash means collisions, and with billions of links they are not rare. You need a lookup and a retry anyway.
  • Con: deduplication is often wrong. Two users shortening the same URL usually want separate links with separate analytics and separate expiry.

Auto-increment counter + base62#

Take a sequential ID from the database and encode it: 1 becomes 1, 1,000,000 becomes 4c92.

  • Pro: no collisions ever, and codes start very short.
  • Con: codes are sequential and guessable. Anyone can walk …4c92, …4c93, …4c94 and scrape every link, including ones meant to be private.
  • Con: one counter is a bottleneck and a single point of failure.

Pre-generated key service (KGS)#

A separate service fills a table with random unused codes ahead of time. App servers grab batches and hand them out.

  • Pro: no collision checks at request time.
  • Con: an extra service to run, and keys held in memory are lost when a server crashes. That waste is harmless, but the service is real operational weight for a problem that is easier to solve directly.

Random code + unique constraint#

Generate 7 random base62 characters from a cryptographically secure random source and insert. The primary key rejects duplicates; on conflict, generate again.

  • Pro: unguessable, no coordination, no extra service, trivially correct.
  • Pro: at 0.2% of the space used, a retry happens about once every 500 inserts, and two retries in a row almost never.
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";

function randomCode(length = 7): string {
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  // 62 does not divide 256, so reject the top values to keep the distribution uniform
  let out = "";
  for (const b of bytes) out += b < 248 ? ALPHABET[b % 62] : "";
  return out.length === length ? out : randomCode(length);
}

async function createLink(longUrl: string, ownerId: number) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const code = randomCode();
    try {
      await db.insert(links).values({ code, longUrl, ownerId });
      return code;
    } catch (e) {
      if (!isUniqueViolation(e)) throw e;   // only retry on a duplicate code
    }
  }
  throw new Error("Could not allocate a short code");
}

My pick: random code with a unique constraint. It is the simplest option that is correct and secure. If write volume ever grows to where retries or a single primary become a problem, switch to range-allocated counters with a bijective scramble: each server reserves a block of a million IDs at a time (INCRBY in Redis or a row in the database), then passes each ID through a reversible permutation, such as a small Feistel network or a library like Sqids, before encoding. Codes stay unique by construction and still look random.

Decision 2: 301 or 302#

This looks like a detail and is actually a product decision.

StatusBrowser behaviourConsequence
301 / 308 PermanentCaches the redirect, often for a long timeFewer requests to you, but you lose click analytics and cannot change or disable the link for that visitor
302 / 307 TemporaryAsks your service every timeEvery click is counted, and links can be edited, expired or blocked instantly

Use 302 for almost every shortener. Analytics, expiry and the ability to kill a malicious link all depend on seeing each click. Use 301 only for links that are truly permanent and where you never need to count or revoke them.

Decision 3: making redirects fast#

With a read-to-write ratio of 100:1, the redirect path is the whole system.

  1. CDN or edge cache. Cache redirect responses at the edge for a short time (seconds to minutes). Viral links are then served without touching your servers. Keep the TTL short so disabling a link takes effect quickly.
  2. Redis cache-aside. On a miss at the edge, look up code in Redis, then the database, and populate Redis. A small fraction of links gets most of the traffic, so even a modest cache has a very high hit rate.
  3. Negative caching. Cache "this code does not exist" too, for a short time. Otherwise a bot scanning random codes turns every request into a database query.
  4. Read replicas behind the cache for the rest.
client ──▶ CDN / edge ──miss──▶ redirect service ──▶ Redis ──miss──▶ DB (replica)
                                      │
                                      └──▶ click event ──▶ queue ──▶ analytics store

Decision 4: analytics without slowing redirects#

Never write to the main database on every click. A link that goes viral would turn thousands of redirects per second into thousands of writes on one hot row.

Instead, the redirect service emits a small event (code, timestamp, referrer, country, user agent) to a queue such as Kafka, Kinesis or SQS, and returns the redirect immediately. A consumer batches events into an analytical store like ClickHouse or BigQuery, which is built for "clicks per day by country" queries. The redirect stays fast even when the analytics pipeline is slow or down.

The parts that bite in production#

Abuse and phishing. Short links hide their destination, which makes them attractive for phishing and malware. Check new URLs against a reputation service like Google Safe Browsing, rate-limit link creation per user and IP, rescan popular links periodically, and keep the ability to disable a code instantly (another reason for 302 and short cache TTLs).

Redirect loops. Reject long URLs that point back to your own domain, or a chain of short links can loop forever.

URL validation. Accept only http and https. Schemes like javascript: or data: turn your redirect into an attack.

Custom aliases. Keep a reserved list (api, admin, login, help) so users cannot claim paths you need, filter offensive words, and decide on case sensitivity up front. Base62 codes are case-sensitive, and some messaging apps and users change case. If links are often typed by hand, consider base58, which drops look-alike characters such as 0, O, I and l.

Expiry and deletion. Store expires_at, check it on read (and in the cache entry), and clean up in a background job. Do not reuse expired codes soon, if ever: old links live on in emails, PDFs and printed QR codes, and reusing one sends those visitors somewhere unexpected.

Multi-region. Codes generated randomly need no coordination between regions, which is another point in their favour. Replicate the links table to every region and serve redirects locally.

The design I would build#

✅
  • Codes: 7 random base62 characters from a secure RNG, inserted with a unique constraint and retried on conflict.
  • Storage: PostgreSQL to start (DynamoDB or Cassandra if scale or multi-region demands it), with the code as primary key.
  • Redirects: 302, served from a CDN with short TTLs, then Redis with negative caching, then read replicas.
  • Analytics: click events to a queue, aggregated in an OLAP store, never written synchronously.
  • Safety: URL validation, reputation checks, rate limits, reserved aliases, and an instant kill switch per code.

None of these pieces is exotic. What makes a shortener reliable is getting the small decisions right: codes that cannot be guessed, a redirect status that keeps you in control, and a read path where a viral link is a cache hit rather than a database incident.

Enjoyed this? Get the next one by email.

Keep reading

Jul 1, 2023

Caching Data from CMS in Next.js

Hey there! This piece will show you how to supercharge your page loading on NextJs with data caching. It's pretty cool, you can use getStaticProps or getServerSideProps to nab data from CMS API and then store it using lru-cache or react-cache - they let you set a lifespan for your data. And that's not all, we're also gonna talk about Redis - a whizz-kid of a global cache solution for your NextJs app.

Jun 15, 2023

Do These 5 Things Before Sleeping: Tips for a Better Night's Sleep

To improve your sleep quality, try eating a few hours before bedtime, taking a shower, burning an organic oil lamp, practicing a nightly yogic chant, and reminding yourself that you're not just your body. These tips can help eliminate negative thoughts from your bedtime routine and improve your overall well-being.