Design URL Shortener: System Design & Architecture

Scope

In Scope

  • Shortening: long URL in, short URL out
  • Redirection: short URL in, user sent to the long URL
  • Expiration: short URLs live for 1 year

Out of Scope

  • Analytics
  • Malware / Phishing detection
  • Letting the user decide what the short url should be
  • Letting the user edit the short url once it has been generated
  • User accounts / authentication (who owns a link)
  • Link management dashboard (listing or searching your created links)

Functional Requirements

  • Create a unique short code for any given long URL, with no duplicate codes ever generated
  • Resolve short codes back to their original URLs and redirect users to the destination
  • Time-based expiration: short URLs automatically expire after 1 year
  • Handle expired or unknown codes gracefully (return 404 / link-not-found rather than redirect)

Non-functional Requirements

  • Low-latency redirects (<10ms p95 at service level)
  • Read-heavy workload: redirects vastly outnumber creates (~90% reads); optimize the read path
  • High availability and durability of mappings with 1-year retention
  • Protect against abuse and spam (e.g. rate limiting)

Back-of-the-Envelope Estimates

  • Assume 10M MAU, 1M DAU; 90% reads (redirects), 10% writes (shorten).
  • Write volume: 1M DAU creating ~0.5 links/day each = ~550k new URLs/day = ~6 writes/sec average. Applying a 10x peak factor gives ~60 writes/sec at peak. The derivation chain is: DAU x links-per-user-per-day, divided by 86,400 seconds (number of seconds in a day), times a peak multiplier.
  • Read volume: a 90/10 read/write split means 9 reads for every 1 write, so we take the ~6 writes/sec average from the line above and multiply by 9 = ~54 reads/sec average. Applying the same 10x peak factor gives ~540 reads/sec at peak. This is why the read (redirect) path is the one we optimize hardest.
  • Storage requirements: ~550k URLs/day x 365 days = ~200M URLs/year (retention is 1 year). At ~200 bytes per mapping (short code + long URL + metadata) that is ~40GB of raw data. The ~200 bytes breaks down roughly as the long URL (~100 bytes, the dominant cost), the 6-byte short code, two 8-byte timestamps (created_at, expires_at), all rounded up to a clean ~200; multiply by 200M rows (200M x 200 bytes = ~40GB). The key takeaway is that 40GB is tiny: it fits comfortably on a single modern node, so the database decision is driven by access pattern (fast key-value lookups at high read throughput) rather than by raw capacity, which is why a key-value store plus a cache is sufficient and heavy sharding for volume alone is unnecessary.
  • Short code length: 6 characters with base62 encoding (0-9, a-z, A-Z) gives 62^6 = ~56.8 billion combinations. Against our ~200M URLs that is ~280x headroom, so collisions are vanishingly rare and a longer code is unnecessary.

High-level Architecture

  • Client → API Gateway (API-key validation + rate limiting) → URL Generation Service → Database (short_code→long_url mapping), and write-through to the Cache on create
  • Client → API Gateway → Redirect Service → Cache; on a cache miss, fall back to the Database and backfill the Cache
  • URL Generation Service handles unique code creation and collision detection
  • Redirect Service handles HTTP 301/302 redirects and cache management for fast lookups
  • TTL/Expiration Service periodically cleans up expired URLs (1-year limit)

Data/User Flow Diagram

[Client]
     |
[API Gateway]  (API-key + rate limiting)
     |
     +-- shorten --> [URL Generation Service] --> [URL Mapping DB]
     |                     |                |
     |                     |                +-- write-through --> [Cache]
     |                     +-- Check Uniqueness --> [URL Mapping DB]
     |
     +-- redirect --> [Redirect Service] --> [Cache]
     |                       |
     |                       +--(miss)--> [URL Mapping DB] --(backfill)--> [Cache]
     |
     v
[TTL/Expiration Service] --> [URL Mapping DB]

Data Model & Storage

  • URL Mapping DB (DynamoDB NoSQL): short_code → { long_url, created_at, expires_at }
  • Cache Layer (Redis): Hot short_code → long_url mappings for fast redirects
  • Expiration Index (DynamoDB GSI): A secondary index on the NoSQL database organized by expiration date to efficiently find and delete expired URLs without scanning the entire database

API Design

POST /v1/shorten
Create unique short code for long URL with 1-year expiration. Returns 201.
GET /:code
Redirect to original URL (301/302).

Low-level Design

  • Short code generation (primary: counter-based): an atomic counter produces a unique integer per URL, which we base62-encode into the short code. Unique by construction, so no collision handling is needed.
  • Base62 encoding: Uses URL-safe characters (a-z, A-Z, 0-9) giving 26 + 26 + 10 = 62 possible characters per position. For a 6-character code, this provides 62^6 = ~56.8 billion unique combinations.
  • Counter weakness - predictability: sequential IDs make codes guessable/enumerable (anyone can scan every URL). Mitigate by encoding through a bijective scramble or adding a large random offset so codes are not sequential.
  • Expiration handling: TTL set to 1 year (365 days) from creation timestamp.
  • Cache strategy: cache-aside on read with TTL-based invalidation. Optionally write-through on create, but only if you assume links are shared and clicked immediately; otherwise write-through pollutes the cache with cold entries that may never be read.

Database Types and Schemas

DB Schemas

- URL Mapping DB (DynamoDB/MongoDB)
  Key: short_code
  Fields: { long_url, created_at, expires_at }

- Cache Layer (Redis)
  Key: short_code -> long_url
  TTL: 1 hour (repopulated on next read after TTL expiry)

- Expiration Index (DynamoDB GSI)
  Partition Key: expires_at (day) → e.g. "2025-10-09"
  Sort Key: short_code → e.g. "a1b2c3"
  
  Query: GSI where expires_at_day = '2025-10-09'
  Returns: All short codes expiring today
  
  Without this GSI, you'd have to scan the whole table to find expired items (terrible at scale).
  With this index, you can just pull "today's bucket" and clean those up.

API Request/Response Payloads

POST /v1/shorten
Request
{
  "long_url": "https://example.com/very/long/path"
}
Response
{
  "short_code": "aB93xY",
  "short_url": "https://short.ly/aB93xY",
  "expires_at": "2027-06-15T10:30:00Z"
}
GET /:code (e.g. GET /aB93xY)
Request
GET /aB93xY HTTP/1.1
Host: short.ly
Response
HTTP/1.1 302 Found
Location: https://example.com/very/long/path

# 404 if the code is unknown or expired:
HTTP/1.1 404 Not Found

Concluding Discussion

Potential Bottlenecks

  • Hot keys (viral links) cause cache churn and KV hotspots: one popular code overwhelms a single cache node/partition while others sit idle. Mitigate by replicating hot keys across multiple cache nodes and using consistent hashing to spread load.
  • Cache stampede: when a hot key expires, thousands of concurrent redirects all miss at once and hammer the DB. Mitigate with a short lock/single-flight on repopulation or staggered TTLs so only one request refills the cache.
  • The single counter limits how fast we can create URLs: to guarantee unique codes, every new URL asks the same one counter for the next number. Because all creates line up at that one counter, it can only hand out numbers so fast - so the counter, not the servers, sets the max create rate (and if it is slow or down, no new URLs can be made). Fix: hand each server a block of numbers in advance (e.g. server A gets 1-1000, server B gets 1001-2000). Each server then gives out codes from its own block without asking the counter every time, and only goes back for a new block when its block runs out. This spreads the work out and removes the single bottleneck.

Single Points of Failure

  • Single KV cluster region without multi-AZ: an AZ failure takes the whole service down. Mitigate with multi-AZ deployment and cross-region replication.
  • Global sequence generator without failover: if the counter dies, no new URLs can be created. Mitigate with pre-allocated ID ranges per server (each grabs a block of IDs and hands them out locally).

Security Improvements

  • Code enumeration: sequential/guessable codes let someone scrape every URL. Mitigate by scrambling counter output (bijective encoding or random offset) so codes are not sequential.
  • Malicious destinations: shorteners are abused to mask phishing/malware links. Detection is out of scope, but validate URL format and support domain allow/deny lists.
  • Abuse and spam: enforce rate limiting at the API gateway (echoes the nonfunctional requirement) to prevent bulk-creation abuse.
  • Transport and privacy: serve over HTTPS only and avoid logging full long URLs (they can contain sensitive query params).

Critical Knowledge

TTL (Time To Live):

  • Automatic expiration mechanism for data in databases and caches
  • Example: URL expires after 1 year → database automatically deletes the record
  • Implementation: store expires_at timestamp, background service periodically scans and removes expired entries
  • Benefits: prevents unlimited data growth, enforces business rules (1-year limit), reduces storage costs
  • Cache TTL: Redis automatically evicts keys after specified time, reducing manual cleanup

Expiration Index:

  • Secondary index organized by expiration time for efficient cleanup
  • Structure: partition by expires_at (day), sort by short_code
  • Why needed: instead of scanning entire database for expired URLs, query specific day partitions
  • Cleanup process: TTL service queries index for yesterday's expired URLs and batch deletes them
  • DynamoDB GSI example: GSI on expires_at enables efficient range queries for cleanup

DynamoDB GSI (Global Secondary Index):

  • Primary table: short_code → {long_url, expires_at}
  • GSI: expires_at → short_codes (for cleanup queries)
  • Benefits: single source of truth with multiple query patterns
  • Auto-sync: GSI automatically stays in sync with main table updates
  • Cost: GSI has separate read/write capacity units

Bijective Encoding (scrambling counter output):

  • Bijective = a one-to-one, reversible mapping: every input maps to exactly one output and back, with no collisions and nothing wasted
  • Problem it solves: a raw counter (1, 2, 3...) base62-encodes into sequential, guessable codes that let anyone enumerate every URL
  • A bijective encoding scrambles the counter value into a random-looking code while staying unique by construction (no collision-retry needed) and reversible

Partition Key (DynamoDB):

  • The primary identifier DynamoDB uses to decide where an item is physically stored and how to find it
  • DynamoDB hashes the partition key value and the hash picks which physical partition (storage node) holds the item; a read by that key hashes again and goes straight to it (~O(1), no scan) - this is the fast redirect lookup our design relies on
  • Two key types: (1) partition key only - must be unique per item, which is our short_code table; (2) partition key + sort key (composite) - items can share a partition key, the sort key orders them within it, and the pair must be unique - our expiration GSI uses expires_at (day) as the partition key and short_code as the sort key so all codes expiring on a day sit together
  • Why it matters: an uneven key distribution creates a hot partition (one node overwhelmed while others sit idle) - this is the viral-link KV hotspot in the Conclusion; sharding the key (e.g. expires_at_day#shard) spreads the load
  • Analogy: a coat-check number - you hand over the coat (item) and get a number (key); the attendant hashes it to a specific rack (partition); the same number later goes straight back to that rack with no searching

Purpose of URL Expiration:

  • Why expire short URLs at all?
  • Storage and cost control: without expiration the mapping table grows forever; a 1-year TTL caps the total stored URLs (~200M in our estimate) and keeps storage and index costs bounded
  • Reclaim the keyspace: expired codes can be retired and their short codes reused, so codes can stay short instead of needing ever-more characters as the table grows
  • Business/product rule: many shorteners promise links live for a set window; expiration enforces that contract
  • Hygiene and safety: stale links (dead destinations, or abused/malicious URLs) age out automatically instead of living forever
  • Trade-off: users lose links after 1 year, so the policy must be communicated

Collision Retry:

  • Different strategies to handle collisions:
  • Increment a counter (deterministic retry): - Add a "seed" number to your input before hashing - First try: hash(url + seed=1) → "a1b2c3". Collision. - Second try: hash(url + seed=2) → "d4e5f6" - Keep going until you find an unused code
  • Append a random suffix (probabilistic retry): - If collision happens, just add some random bits and try again - Example: hash(url + random_suffix) → "g7h8i9" - The chance of repeating collisions is very small if the random space is large enough
  • Use a different hash algorithm or salt: - Change the hashing formula if collisions occur - Example: instead of SHA256(url), try SHA256(url + "salt2") - This makes the hash output very different from the original, so collisions are unlikely to repeat

Cache-aside on Read (vs Write-through):

  • Cache-aside: the application manages the cache explicitly. Read flow: check cache first → on miss, query the database → store the result in the cache. Only data that is actually requested ever gets cached
  • Write-through alternative: every write goes to the cache and the database at the same time, so the cache is populated at create time
  • Why cache-aside is better here: most newly created short URLs may never be clicked (or not for a long time), so write-through would fill the cache with cold entries that waste memory and evict genuinely hot keys; cache-aside only caches links people actually visit, which matches our read-heavy, hot-key access pattern
  • Cost of cache-aside: the first read after a miss is slightly slower (one extra DB round-trip), which is an acceptable trade
  • URL shortener: the redirect service checks Redis → on miss queries the URL Mapping DB → caches the result for future redirects

Replicating Hot Keys + Consistent Hashing (hotspot mitigations):

  • The problem: a cache normally maps each key to ONE node, so a single viral code sends all its traffic to that one node while the rest sit idle (a hotspot)
  • Replicating hot keys: copy the few hottest entries onto multiple nodes (or add read replicas) and spread reads across the copies, so no single node absorbs the whole spike. Trade-off: extra memory and you must keep the copies in sync, so only do it for the small set of genuinely hot keys
  • Consistent hashing: a scheme for deciding which node holds a key by placing both nodes and keys on a hash ring; a key goes to the next node clockwise. Its benefit is that adding or removing a node only remaps the keys near that node (~1/N) instead of reshuffling everything, so the cache stays mostly warm during scaling or failures
  • Virtual nodes: each physical node is placed at many points on the ring so load is spread evenly and no single node owns a huge arc
  • Together: consistent hashing distributes keys evenly and makes scaling cheap; hot-key replication handles the one-key-too-popular case that even perfect distribution cannot fix

Hot Codes Causing Cache Churn and KV Hotspots:

  • Popular short URLs getting millions of redirects create uneven load
  • Cache churn: hot codes evict other cached entries → reduced hit rate
  • KV hotspots: single database partition overwhelmed by reads for popular codes
  • Example: viral link shared on social media → 100K redirects/minute to same short_code
  • Solutions: replicate hot data across multiple cache nodes, use consistent hashing to distribute load, implement rate limiting

Single KV Cluster Region Without Multi-AZ:

  • All URL mappings stored in one availability zone (AZ)
  • Risk: AZ failure → entire service down, no failover capability
  • Data loss risk: single point of failure for all short URL mappings
  • Example: AWS us-east-1a fails → all short codes become unreachable
  • Solutions: multi-AZ deployment, cross-region replication, database clustering across zones

Global Sequence Generator Without Failover:

  • Centralized counter for generating unique short codes
  • Single point of failure: if generator goes down, no new URLs can be created
  • Bottleneck: all URL creation requests must go through one service
  • Example: counter service in single server → server crashes → no new short codes possible
  • Solutions: distributed ID generation (Snowflake) or multiple generators with partitioned ranges

301 vs 302 Redirect:

  • 301 (permanent): cached by the browser, so repeat clicks never reach our service - that breaks 1-year expiration (an expired link still redirects from cache) and kills any future analytics
  • 302 (temporary): routes every click through us, preserving expiration enforcement and future click-tracking, at the cost of more traffic
  • Recommendation: use 302, because our expiration requirement means we must stay in the request path
  • Edge cases: return 404 if the code is unknown or expired; the GET path is intentionally unversioned since the short URL is the bare domain/code

Why the Host header is needed in the request:

  • In HTTP/1.1 the request line carries only the path (e.g. GET /aB93xY), not the domain
  • The Host header (Host: short.ly) is what tells the server which domain the request is for
  • This is what lets one server (or IP) serve many domains - it routes the request to the right site (virtual hosting)
  • For a URL shortener whose entire identity is the short domain, Host is what ties the bare code to short.ly
  • HTTP/1.1 requires it: a request with no Host header is malformed and the server should reject it with a 400