Designing a URL Shortener

A system design walkthrough for building a service like TinyURL or bit.ly: how a long URL becomes a short slug, how that slug is stored and looked up, and why almost every decision is shaped by the fact that reads vastly outnumber writes.

Student edition · start with the guarantee

Build an atomic short-code mapping and a correct redirect path, including expiry, caching, and guessability.

Correctness contract: Each published slug identifies one committed mapping. A duplicate create follows the API policy; lookup honors disable/expiry rules within its stated caching and revocation limits.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

A URL shortener takes a long, unwieldy link and hands back a short one — https://example.com/abc123 — that redirects to the original. The product is deceptively simple, which is exactly why it is a popular interview question: the requirements are small enough to fully specify in a few minutes, yet the design forces you to reason about ID generation, storage schemas, caching, and the semantics of HTTP redirects. This guide walks the problem from requirements to a working design, keeping the discussion generic and educational rather than tied to any one company's implementation.

Contents

  1. Requirements and Estimates
  2. API Design
  3. Write Path and Read Path
  4. Two Ways to Generate the ID
  5. Base62 and the Length Math
  6. Data Model
  7. Redirect Semantics: 301 vs 302
  8. Caching the Hot Slugs
  9. Failure Modes
  10. Tradeoffs
  11. A 45-Minute Interview Walkthrough
  12. Common Mistakes
  13. Summary
  14. Check Yourself
  15. Key Terms
  16. Further Reading
  17. Concepts to understand first
  18. Worked example
  19. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Base62 encoding

A reversible representation of a nonnegative integer using 62 symbols, typically digits and upper/lowercase letters. Repeated division by 62 emits the digits; decoding multiplies the accumulator by 62 and adds each digit.

Example. With alphabet 0–9, a–z, A–Z: 125=2×62+1 becomes '21'. Seven characters provide 62^7=3,521,614,606,208 possible fixed-width strings.

Limit. Encoding is not encryption or access control. Validate negative/empty/invalid inputs, preserve case and the alphabet, and do not assume a sequential ID becomes unguessable.

Idempotency keys

A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect.

Example. Payment p42 times out after the provider charged it. Retrying with p42 lets the provider return the same charge instead of creating a new one.

Limit. A unique local claim is insufficient if the business effect and saved result can diverge. Keep unknown outcomes pending, use downstream idempotency, and state the retention/retry horizon.

Cache-aside and invalidation

A read checks a cache, reads the authoritative store on a miss, then fills the cache. A write commits to the store and invalidates or refreshes the cached value. TTL means time to live, not a freshness proof.

Example. A reader fetches version 1, a writer commits version 2 and invalidates, then the delayed reader caches version 1. DB-first invalidation alone does not prevent this refill race.

Limit. Use invalidation-aware fill leases or version fences for stronger behavior; a TTL only bounds one entry's residence under additional assumptions. Authorization and revocation need an explicit stale-data policy.

Optimistic concurrency and compare-and-set

Update state only if the expected version or status still matches, in the same atomic operation that changes it. A failed comparison means a conflicting operation won; reread or return a conflict.

Example. UPDATE seats SET status='held', version=version+1 WHERE id=? AND status='available' allows only one concurrent claimant to update the row.

Limit. Application-level GET then unconditional SET is not equivalent. Include acquisition identity to reject stale callbacks, and couple related invariants in a transaction.

Replication

Maintaining copies of data on independent storage nodes. Synchronous acknowledgement waits for specified durable copies; asynchronous replication sends changes after the client has already received success.

Example. A primary acknowledges after two durable copies in different zones. A follower that applies updates later can serve an old value even though the write is durable.

Limit. Replica count is not a complete durability or availability guarantee. State the failure domains, acknowledgement policy, promotion rules, repair process, and what happens if all copies fail.

1. Requirements and Estimates

Before sketching anything, pin down what the service must do. The functional requirements are short: given a long URL, return a short URL; given a short URL, redirect the caller to the original long URL. Optional extras — custom aliases, link expiry, analytics, user accounts — are worth naming so you can decide what is in scope, but the core is just those two operations.

The non-functional requirements are where the design gets its shape. The system should be highly available (a dead redirect breaks every link that was ever shared), redirects should be low-latency, and short URLs must not be guessable in a way that leaks others' links. The single most important observation, though, is the traffic shape.

PropertyAssumptionWhy it matters
Read:write ratioRoughly 100:1 — links are created once and clicked many times.The system is overwhelmingly a read service. Optimize the redirect path and cache aggressively.
New URLs per monthSay ~100M creates/month.Drives the key space: how long a slug must be to never run out.
Redirects per month~10B at 100:1.Drives read throughput and the need for a cache tier in front of the store.
RetentionLinks live for years.Storage grows steadily; the slug length must cover many years of creates.

A quick back-of-the-envelope pass: 100M writes per month is roughly 40 writes per second on average. At a 100:1 ratio that is about 4,000 reads per second, with peaks well above that. Storage is modest per row — about 500 bytes for the long URL plus metadata — so 100M rows a month is about 50 GB a month, 0.6 TB a year, and 6 TB over ten years. That fits a small sharded key-value store (or a few replicated database servers) comfortably. The dominant cost is not storage; it is serving the read flood quickly and reliably.

The read-heavy ratio is the thesis of the whole design. Almost every later choice — caching, a 301 redirect, a denormalized lookup table — exists to make the read path cheap. If you remember one thing from this problem, make it that reads dwarf writes by two orders of magnitude.

Clarifying questions worth asking before you commit: Can users pick custom aliases? Do links expire? Can a link be edited or deleted? Do we need click analytics? Should the same long URL always return the same short URL? How short must the slug be?

Estimation, step by step

QuantityWorkingResult
Writes per second100M ÷ (30 × 86,400 s)~39 /s (peak ~80)
Reads per second39 × 100~3,900 /s (peak ~8k)
Storage per month100M × 500 B50 GB
Links in 10 years100M × 120 months12 billion
Mapping cache RAMDistinct resident mappings × measured entry bytes, with metadata/replica/headroom overhead.Cannot infer from daily redirect request count alone.
Storage in 10 years12B × 500 B6 TB

The 20% cache rule comes from the 80/20 rule: a small share of links gets most clicks. 33 GB fits in memory on one or two cache servers.

2. API Design

POST /api/v1/urls
  body:    {"long_url": "https://example.com/very/long/path", "alias": "optional", "expires_at": "optional"}
  returns: 201 {"short_url": "https://sho.rt/abc123"}
           400 invalid URL, 409 alias taken, 429 too many requests

GET /{slug}
  returns: 301 or 302 with header  Location: https://example.com/very/long/path
           404 unknown slug, 410 expired or deleted
Tip

Put the create endpoint behind auth or a rate limiter. Otherwise spammers use your domain to hide phishing links, and browsers may start flagging all of your short links.

3. Write Path and Read Path

The service has exactly two flows, and it pays to draw them separately because they are asymmetric. The write path runs rarely and can afford to do real work; the read path runs constantly and must be as short as possible.

URL shortener write path
Write path: a long URL is turned into a unique ID, base62-encoded into a slug, checked for collisions, and persisted as a new row mapping the id, short URL, and long URL before the short URL is returned.

On a create request, the service generates an identifier for the new link, encodes that identifier into a short slug using base62, confirms the slug is not already taken (a check that is trivial or unnecessary depending on the ID strategy below), stores the mapping, and returns the assembled short URL to the caller. This is the expensive path, but it runs only on the order of tens of times per second.

URL shortener read path
Read path: a GET on the slug looks it up in the cache, falling back to the database; a miss returns 404, while a hit issues a 301 redirect that the browser follows to the long URL.

On a redirect request — a GET /abc123 — the service extracts the slug, looks up the corresponding long URL, and responds with an HTTP redirect pointing at it. The browser then follows that redirect to the destination. If the slug does not exist, the service returns 404 Not Found. The lookup checks an in-memory cache first and only touches the database on a miss. Keeping this path to a single fast lookup is the entire performance game.

4. Two Ways to Generate the ID

Separate code uniqueness from unguessability. A unique sequence or correctly allocated Snowflake ID encoded in base62 is compact but enumerable. For public unguessable links, generate sufficiently long slugs with a cryptographically secure random generator and atomically insert under a unique constraint. Unguessability does not replace access control for private resources.

Seven fixed base62 characters provide 62^7≈3.52×10^12 possibilities. Uniform random samples can collide long before the space is full; at n samples, expected colliding pairs are approximately n(n−1)/(2M). Handle conflicts at insert, cap retries, and monitor occupancy. Taking the leading characters of a variable-width base62 hash string is not necessarily a uniform fixed-width slug generator.

Hashing the long URL does not make the service stateless: collision handling, expiry, ownership, aliases, and stored mappings are still state. Deduplication by destination must be scoped and evaluated for privacy. An ad-hoc small Feistel permutation is not an authorization/security proof; use reviewed cryptography if that is truly required.

5. Base62 and the Length Math

Fix one case-sensitive alphabet and validate inputs. Base62 is a representation of an integer, not encryption. With alphabet 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ, 125 becomes 21.

ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'

def encode(n):
    if not isinstance(n, int) or isinstance(n, bool) or n < 0:
        raise ValueError('nonnegative integer required')
    if n == 0:
        return '0'
    digits = []
    # Invariant: emitted reversed digits plus remaining n encode the input.
    # Bound: n strictly decreases while positive.
    while n:
        n, remainder = divmod(n, 62)
        digits.append(ALPHABET[remainder])
    return ''.join(reversed(digits))

def decode(code):
    if not code or len(code) > 32:
        raise ValueError('nonempty bounded code required')
    n = 0
    for char in code:
        n = n * 62 + ALPHABET.index(char)  # invalid symbols raise ValueError
    return n

assert encode(125) == '21' and decode('21') == 125

Leading zeroes decode to the same number; reject noncanonical encodings if the API requires one representation. A full nonnegative signed 63-bit ID may need 11 characters, so a seven-character limit also constrains the allocator.

6. Data Model

The storage need is a single mapping from slug to long URL, which is about as simple as a schema gets. A relational table or a key-value store both work; the access pattern (look up by slug) is a primary-key read either way.

ColumnTypePurpose
id64-bit integer (primary key)The unique identifier the slug is encoded from. Also the natural primary key.
shortURLvarchar (indexed/unique)The base62 slug, e.g. abc123. Looked up on every redirect, so it must be indexed.
longURLtextThe original destination the slug redirects to.
created_attimestamp (optional)When the link was created — useful for expiry and analytics.
user_ididentifier (optional)Owner of the link, if the service has accounts.

The write step is therefore a single insert: create a new row carrying the id, the shortURL slug, and the longURL destination (plus any optional metadata). Because the redirect path looks up by slug, the shortURL column carries a unique index so the lookup is an indexed point read rather than a scan.

# create: one row insert
INSERT INTO urls (id, shortURL, longURL, created_at)
VALUES (9326731923, 'abc123', 'https://example.com/some/very/long/path', now());

# redirect: indexed point lookup
SELECT longURL FROM urls WHERE shortURL = 'abc123';

7. Redirect Semantics: 301 vs 302

301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.

StatusMeaningCaching behaviorBest when
301 Moved PermanentlyThe slug permanently maps to this long URL.Browsers cache the redirect. After the first hit, the browser jumps straight to the long URL without calling your server again.You want to minimize load on your service and the mapping never changes.
301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.The slug temporarily points here; ask again next time.Browsers do not cache it by default (only if you send explicit Cache-Control headers). Every click comes back through your server.You want to count clicks, run analytics, A/B test destinations, or change where a slug points.

301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.

Middle ground

301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.

8. Caching the Hot Slugs

Size the cache from unique hot mappings, not redirect request count: resident keys × measured bytes per entry, plus metadata/allocator overhead, replicas, and headroom. A 20% share of daily requests is not automatically 20% of distinct keys or a universal Pareto law.

Cache mapping metadata, including expiry/disabled state and version, with a documented maximum stale-serving policy. DB-first invalidation still has delayed-fill races; version/invalidation fences are needed for stronger guarantees. Negative lookups need a short TTL and create-time invalidation. Ensure a newly published slug is readable on the selected replica path before returning success.

For mutable, revocable, or analytics-sensitive destinations, choose redirect and Cache-Control policies deliberately. Permanent client caches cannot reliably be recalled by purging your own Redis/CDN entries. Validate destination schemes (usually HTTP/HTTPS); any server-side scanning/fetching also needs SSRF protections for resolved addresses and every redirect.

9. Failure Modes

What failsEffectMitigation
Cache cluster downAll ~8k reads/s hit the databaseSize the database for cache-less peak, or shed load; replicas of the cache
Database primary downNew links fail; redirects keep working from cache and replicasAutomatic failover; reads served by replicas
Counter service downServers can still spend their leased rangesLease bigger ranges; run the counter with a replicated store
A viral linkOne hot key hammers one cache nodeLocal in-process cache on each web server; 301 with short max-age
Abuse (phishing, spam)Domain gets blocklisted by browsersRate limit creates, scan destinations, allow takedowns (410)

10. Tradeoffs

ChoiceOption AOption B
Slug sourceHash: stateless, same URL gives same slug, but collisionsCounter + base62: no collisions, needs a counter, guessable unless scrambled
Redirect code301: least load, lose click data301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.
StoreSQL: easy unique index and aliasesKey-value (e.g. DynamoDB, Cassandra): simple sharding by slug
Duplicate long URLsReuse slug: saves space, needs a long-URL indexNew slug each time: simpler, per-user analytics stay separate

Worked example: an enumerable code and a cached redirect

A unique counter and a random slug solve different problems.

Worked example, in orderEncode an ID: 125 encodes as '21' in the documented base62 alphabet. The neighboring ID is easy to enumerate: encoding does not hide it.. Claim the mapping: A database unique constraint atomically claims the slug. Random-generation collisions retry with a bounded policy; a check before insert is not enough.. Redirect the client: 301 may be cached; 302 may also be cached with explicit freshness headers. If every redirect must reach the service, configure Cache-Control deliberately rather than relying on 302 alone.. Disable a destination: Invalidate server/edge entries, but a browser holding a permanent cached redirect may keep bypassing you. Revocation and analytics requirements therefore influence status and cache policy.1Encode an ID2Claim the mapping3Redirect the client4Disable a destination
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Encode an ID125 encodes as '21' in the documented base62 alphabet. The neighboring ID is easy to enumerate: encoding does not hide it.
2. Claim the mappingA database unique constraint atomically claims the slug. Random-generation collisions retry with a bounded policy; a check before insert is not enough.
3. Redirect the client301 may be cached; 302 may also be cached with explicit freshness headers. If every redirect must reach the service, configure Cache-Control deliberately rather than relying on 302 alone.
4. Disable a destinationInvalidate server/edge entries, but a browser holding a permanent cached redirect may keep bypassing you. Revocation and analytics requirements therefore influence status and cache policy.
Check your understanding: Can daily request count determine mapping-cache RAM?

No. Cache RAM depends on distinct resident mappings and entry overhead. Many repeated requests may touch one key; measure hit ratio and working-set size.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py encoding
python3 concepts.py test

The model round-trips integers, rejects negative/invalid input, and shows why base62 is not encryption.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

11. A 45-Minute Interview Walkthrough

TimeWhat to do
0–5 minClarify features (aliases, expiry, analytics) and scale.
5–10 minEstimate: 39 writes/s, 3.9k reads/s, 6 TB over 10 years, 7-char slugs.
10–15 minAPI: POST to create, GET slug to redirect.
15–25 minHigh-level design: load balancer, web servers, cache, database, counter.
25–40 min301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.
40–45 minFailure modes, abuse, analytics pipeline (log clicks to a queue).

12. Common Mistakes

13. Summary

The URL shortener is a small system whose design follows almost entirely from one fact — reads dominate writes — and a couple of clean encoding choices.

ConcernDecision
What shapes the design?A ~100:1 read:write ratio. The redirect path is the hot path; optimize and cache it.
How is the slug generated?Prefer a unique 64-bit id base62-encoded (no collisions). Alternatively hash the URL and resolve collisions by rehashing or lengthening.
Why base62?URL-safe characters (0-9a-zA-Z) and compact: 62n grows far faster than 10n, so 6-7 characters cover billions to trillions of links.
How is the mapping stored?A row per link with id, shortURL, longURL (plus optional created_at, user_id); the slug column is uniquely indexed.
Which redirect code?301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.
How are ids handed out?Each server leases a range from a counter; scramble ids with a secret permutation if slugs must not be guessable.
How are reads kept fast?An LRU in-memory cache of hot slugs in front of the database; a miss falls back to the store and warms the cache.
The recurring theme: the service is a read system wearing a write system's clothes. Generate a guaranteed-unique slug cheaply, store one flat row, and then spend all of your engineering effort making the redirect lookup fast through caching and the right redirect semantics.

14. Check Yourself

  1. At 100M new links a month, how many 6-character base62 slugs are left after 10 years?
    Answer

    626 ≈ 56.8 billion, minus 12 billion used, leaves about 44.8 billion. Six characters would last about 47 years.

  2. What is encode(125) in base62 with alphabet 0-9a-zA-Z?
    Answer

    125 = 2 × 62 + 1, so the digits are 2 and 1: "21".

  3. Why does a counter-based slug need no collision check, while a hash-based slug does?
    Answer

    Base62 is a one-to-one map from integers to strings, and every counter value is used once. A 7-character hash prefix squeezes a huge input space into 3.5 trillion values, so different URLs can land on the same prefix.

  4. Marketing wants a click count for every link. Which redirect code do you use, and what does it cost?
    Answer

    301 is a permanent redirect and may be cached; 302 is temporary but can also be cached under explicit headers. Choose Cache-Control deliberately for expiry, revocation, target mutability, and analytics. Neither status alone guarantees every click returns to this service.

  5. An app server that leased ids 5,001–6,000 crashes after using 200. What happens?
    Answer

    Ids 5,201–6,000 are never used. That is fine: slugs only need to be unique, not gap-free.

  6. How would you stop people from guessing other users' links?
    Answer

    Scramble the counter value with a secret one-to-one permutation (for example a Feistel network) before base62, or add random characters. Also rate limit lookups of unknown slugs.

15. Key Terms

Slug
The short code at the end of the short URL.
Base62
A reversible representation of a nonnegative integer using 62 symbols, typically digits and upper/lowercase letters. Repeated division by 62 emits the digits; decoding multiplies the accumulator by 62 and adds each digit. Example and limits above.
301 / 302
Permanent and temporary HTTP redirects.
Location header
The response header that holds the redirect target.
Range allocation
Leasing a block of ids so servers rarely coordinate.
Birthday paradox
Random values start colliding after about the square root of the space.
Feistel network
A reversible way to shuffle numbers with a secret key.
LRU cache
A cache that evicts the least recently used entry first.

16. Further Reading


⌂