Skip to main content

URL Shortener: Deep Dives

Editorial

What ID generation strategy should we use, and why?

Level: Mid-level

The ID must have two properties: global uniqueness (no two URLs share a code) and shortness (compact enough to be useful as a short URL). These properties are in tension — longer IDs are easier to make unique; shorter IDs risk collisions.

The fundamental approach: generate a unique integer, then encode it into a compact string. Let's evaluate the integer generation options:

Option 1: Hash Functions (MD5 / SHA-256)

Hash the original URL to produce a fixed-length digest, then truncate to the desired length.

HashOutput SizeTruncated to 6 charsCollision Risk
MD5128 bits (32 hex chars)First 6 hex chars → 16⁶ = 16.7M IDsHIGH — birthday paradox: 50% collision at ~4,800 URLs
SHA-256256 bits (64 hex chars)First 6 Base62 chars → 56.8B IDsStill requires collision detection

Problem: Truncation dramatically increases collision probability. Even with collision detection and retry logic, the write path becomes: hash → check DB → collision? → re-hash with salt → check again. Under load, this creates hot-path database reads on every write.

Collision Math: Birthday Paradox

The birthday paradox tells us collisions appear much sooner than expected. For a hash space of size N, the probability of at least one collision after k insertions is approximately:

P(collision)≈1−e−k2/2NP(\text{collision}) \approx 1 - e^{-k^2 / 2N}

For 6 hex characters (N = 16⁶ = 16,777,216):

  • After 1,000 URLs: P ≈ 3%
  • After 4,800 URLs: P ≈ 50%
  • After 10,000 URLs: P ≈ 95%

For 6 Base62 characters (N = 62⁶ = 56,800,235,584):

  • After 100,000 URLs: P ≈ 8%
  • After 280,000 URLs: P ≈ 50%
  • After 1,000,000 URLs: P ≈ 99.98%

Even with the larger Base62 space, a collision becomes more likely than not after about 280,000 URLs — under 7 hours of traffic at 1M new URLs per day. Hash-based IDs need collision detection from day one.

Option 2: UUID (v4 Random / v1 Timestamp)

UUID VersionFormatLengthUniqueness
UUIDv4Random 122-bit36 chars (f47ac10b-58cc-4372-...)Extremely unlikely collisions
UUIDv1Timestamp + MAC36 charsGuaranteed unique per machine

Problem: 36 characters defeats the purpose of URL shortening. Even Base62-encoding a UUIDv4 produces ~22 characters. UUIDs solve uniqueness but fail the shortness requirement.

Option 3: Snowflake IDs (Twitter-style)

Structure: [timestamp: 41 bits][machine_id: 10 bits][sequence: 12 bits] = 64-bit integer.

PropertyValue
Total bits64
Base62 encoded length~11 characters
Max machines1,024
Max IDs per ms per machine4,096

Problem: Base62 encoding of a 64-bit integer produces ~11 characters. Still too long for a "short" URL. We could truncate, but that reintroduces collision risk.

Option 4: Machine ID + Sequence Number (Recommended)

Assign each server a unique 1-character prefix. Each server maintains an auto-incrementing counter. The ID is: MachinePrefix + SequenceNumber.

With 1 character for machine ID (62 options using Base62) and 5 characters for sequence (62⁵ = 916M per machine):

ComponentBits/CharsCapacity
Machine prefix1 Base62 char62 machines
Sequence number5 Base62 chars916M per machine
Total capacity6 chars62 × 916M = 56.8B
import os

CHECKPOINT_EVERY = 1_000

class IDGenerator:
    def __init__(self, machine_id: str):
        """
        machine_id: single Base62 character ('0' through 'z')
        """
        assert len(machine_id) == 1
        self.machine_id = machine_id
        self.sequence = self._load_last_sequence()  # Recover after restarts

    def next_id(self) -> str:
        self.sequence += 1
        if self.sequence % CHECKPOINT_EVERY == 0:
            self._persist_sequence()  # Checkpoint every 1,000 IDs
        encoded_seq = base62_encode(self.sequence).zfill(5)  # Pad to 5 chars ('0' is the zero digit)
        short_code = self.machine_id + encoded_seq
        return short_code

    def _load_last_sequence(self) -> int:
        """Resume past the last checkpoint: up to 1,000 IDs after it may already be in use."""
        try:
            with open(f"/var/data/sequence_{self.machine_id}.txt") as f:
                return int(f.read().strip()) + CHECKPOINT_EVERY
        except FileNotFoundError:
            return 0

    def _persist_sequence(self):
        """Write the checkpoint atomically, so a crash mid-write cannot corrupt it."""
        path = f"/var/data/sequence_{self.machine_id}.txt"
        with open(path + ".tmp", "w") as f:
            f.write(str(self.sequence))
        os.replace(path + ".tmp", path)

Advantages over alternatives:

StrategyLengthUniquenessCoordinationComplexity
Hash + truncation6+Requires collision detectionNoneMedium (retry logic)
UUID22+ (Base62)GuaranteedNoneLow
Snowflake11+ (Base62)GuaranteedClock syncMedium
Machine ID + Sequence6Guaranteed (by construction)Machine ID assignmentLow

The Machine ID + Sequence approach produces the shortest IDs with guaranteed uniqueness and no runtime coordination between servers. The only coordination point is machine ID assignment — a one-time setup operation when adding servers.

Handling Server Restarts and Sequence Recovery

If a server crashes and restarts, it must not reuse sequence numbers. Three approaches:

  1. Persistent checkpoint (recommended): Write the current sequence to disk every N IDs (e.g., every 1,000). On restart, resume from the last checkpoint + 1,000 (skip the gap to avoid any overlap).

  2. Database-backed sequence: Use a database counter (SELECT last_seq FROM sequences WHERE machine_id = 'A' FOR UPDATE). Reliable but adds a DB call to every write.

  3. Time-based offset: On restart, set the sequence to (seconds since the service launched) × (the machine's maximum ID rate). As long as the machine never issues IDs faster than that rate, this is always past every ID already issued. It needs no persistent state, but it burns ID space quickly, so it only suits machines with a low maximum rate.

The checkpoint approach balances reliability with performance — you lose at most 1,000 IDs per crash (0.0001% of the per-machine capacity of 916M).

Pre-generating IDs in Bulk

Under sudden traffic spikes, generating IDs on-demand could introduce latency if the sequence checkpoint or encoding becomes a bottleneck. Pre-generating a batch of IDs eliminates this concern:

class BulkIDGenerator(IDGenerator):
    BATCH_SIZE = 10_000

    def __init__(self, machine_id: str):
        self.machine_id = machine_id
        self.buffer = []
        self.sequence = self._load_last_sequence()
        self._refill()

    def next_id(self) -> str:
        if not self.buffer:
            self._refill()
        return self.buffer.pop()

    def _refill(self):
        batch = []
        for _ in range(self.BATCH_SIZE):
            self.sequence += 1
            batch.append(self.machine_id + base62_encode(self.sequence).zfill(5))
        self.buffer = batch
        self._persist_sequence()

Trade-off: Pre-generation wastes IDs if the server crashes before using all buffered IDs. With a 10K buffer and 916M capacity per machine, this waste is negligible (0.001%).

How should we encode IDs into short, URL-safe strings?

Level: Mid-level

After generating a unique integer, we need to encode it into a compact, human-readable string. The encoding must balance shortness with usability — avoiding characters that are confusing to type or problematic in URLs.

Encoding Options Comparison

EncodingCharactersCapacity (6 chars)Chars for 56B IDsIssues
Base16 (Hex)0-9, a-f16⁶ = 16.7M9 charsOnly 16 chars → longer strings
Base360-9, a-z36⁶ = 2.2B7 charsCase-insensitive (loses half the alphabet)
Base58A-Z, a-z, 0-9 minus 0OIl58⁶ = 38B7 charsAvoids confusing characters but non-standard
Base62a-z, A-Z, 0-962⁶ = 56.8B6 charsNo special characters, URL-safe
Base64A-Z, a-z, 0-9, +, /, =64⁶ = 68.7B6 chars+, /, = problematic in URLs

Why Base62?

Base62 is the optimal choice because:

  1. No special characters — Base64's / splits a URL path, and + and = are often rewritten by encoders, so all three end up percent-encoded (%2F, %2B, %3D), defeating the purpose of shortening. URL-safe Base64 swaps them for - and _, but those are easy to lose when a link is underlined or wrapped at a line end, so shorteners usually stick to letters and digits
  2. 6 characters suffice — 62⁶ = 56.8B provides 30× headroom over our 5-year requirement
  3. Human-friendly — All characters are easily typeable on any keyboard
  4. Case-sensitive by definition — URLs are case-sensitive after the domain, so a1B2c3 and A1b2C3 are different short codes

Why Not Base58? (Bitcoin's Choice)

Base58 removes visually ambiguous characters: 0 (zero) vs O (letter O), I (uppercase i) vs l (lowercase L). This is valuable for Bitcoin addresses that are manually transcribed.

For URLs, visual ambiguity matters less — users click links, they don't type them. The extra 4 characters (0, O, I, l) give us Base62's larger space (56.8B vs 38B for same length), reducing the likelihood of needing 7-character codes.

When to choose Base58: If your short URLs appear on billboards, business cards, or physical media where humans must type them. When to choose Base62: If URLs are primarily shared digitally (links, messages, emails).

Five encodings compared, one row each, with the lesson's largest 6-character Base62 code, 56,800,235,583, written in each alphabet. Base16 (0-9 a-f): six characters hold 16.7M; the number is d398ed03f, 9 characters. Base36 (0-9 a-z): 2.2B; q3de69r, 7 characters, one case. Base58 (no 0 O I l): 38B; 2VYF49c, 7 characters, no look-alikes. Base62 (0-9 A-Z a-z, highlighted): 56.8B; zzzzzz, 6 characters, URL-safe. Base64 (adds + and /): 68.7B; 05jtA/, 6 characters, but / and + break URLs, and its / is marked red. A dashed line marks 5 years of links, 1.825B, on the capacity bars. A callout: Base62 in 6 characters gives 56.8B codes, ~30× the 1.825B links of 5 years. The title reads: why Base62: 6 characters, all URL-safe.

How do we scale the system to handle high traffic?

Level: Senior

Request handlers are stateless HTTP servers — scaling them is trivial (add more instances behind the load balancer). The interesting scaling challenges are in the ID generator and the database.

Scaling the ID Generator

Each ID generator uses 1 character for machine ID, giving us 62 possible machines. Each machine generates up to 916M IDs. But how do we route requests to them?

Load balancing strategies:

  • Random selection: Each request handler picks a random ID generator. Simplest, good load distribution.
  • Least-loaded: Pick the generator with the shortest queue. Better under skewed load.
  • Sticky routing: Route by client ID. Unnecessary (any generator works for any request).

Sharding the Database

At our estimates (12 writes/sec, about 1 TB over five years), one PostgreSQL primary with read replicas is enough: the cache absorbs most reads, and the writes are light. Sharding becomes necessary when the data or the write rate outgrows one primary, for example at 100× today's volume. When that happens, this design already has a natural shard key: the machine ID prefix in each short code serves double duty as the shard key. Each machine writes to its own database shard — the write paths are completely independent.

Shard key = Machine ID prefix in the short code.

When the machine with prefix a generates code a1B2c3, it writes to shard a. When the machine with prefix B generates B7x9pQ, it writes to shard B. No cross-shard coordination is needed for writes.

Sharding by the code's prefix. A banner: only when one primary is outgrown, e.g. 100× today's volume. GET /a1B2c3 and POST /api/urls/shorten reach a LOAD BALANCER. Writes go to any SERVER; three servers are drawn as dashed boxes, each with its prefix in a key cap (a, B, c) and issuing codes that start with it (a·····, B·····, c·····), and each INSERTs into its own shard: SHARD a, SHARD B, SHARD c, 916M codes each; dots mark more lanes. Reads go to any instance of the REDIRECT HANDLER; on a miss, a1B2c3 starts with a, so it reads shard a. Footer: up to 62 prefixes, 62 shards; no routing table, no cross-shard writes; a new server adds a shard, no existing data moves. The title reads: the prefix is the shard key.

Write path: Completely parallel. Each machine writes to its own shard. No coordination, no locking, no cross-shard transactions. Adding a new machine = adding a new shard. Existing shards are unaffected.

Read path: On a cache miss, the redirect handler extracts the first character of the short code and routes to the correct shard. For example, a1B2c3 → shard a.

Why This Sharding Strategy Works

BenefitExplanation
No rebalancingUnlike consistent hashing, adding a new shard doesn't redistribute existing data. All old data stays where it is.
Deterministic routingThe shard is encoded in the short code itself. No routing table lookup needed.
Independent scalingIf shard a gets hot, add a read replica. Other shards are unaffected.
Simple failure isolationIf shard B fails, only codes starting with B are affected. All other codes work fine.

Scaling Request Handlers and ID Generators Independently

Should request handlers and ID generators be 1:1? No — they have different scaling profiles:

Request handlers are I/O-bound: handling HTTP connections, checking cache, holding sockets open. They need many instances for concurrency — often 10-50 instances for high traffic.

ID generators are CPU/IO-bound: generating IDs and writing to disk/DB. Each generator can handle thousands of IDs per second. At 12 QPS average, even a single generator has enormous headroom.

Typical ratio: 10-20 request handlers per ID generator.

Routing: Request handlers pick an ID generator using round-robin or least-loaded selection. Since any generator can serve any request, the routing doesn't need to be sticky.

Database Choice: DynamoDB vs Cassandra vs PostgreSQL

DatabaseStrengthsWeaknessesBest For
DynamoDBManaged, auto-scales, single-digit ms latency, built-in partitioningExpensive at high throughput, limited query flexibilityAWS-native teams, low operational overhead
CassandraLinear horizontal scaling, tunable consistency, handles massive write throughputOperational complexity, no transactions, limited secondary indexesWrite-heavy workloads, multi-region
PostgreSQL (sharded)Rich query support, ACID transactions, familiar toolingManual sharding, scaling requires application-level routingTeams with PostgreSQL expertise, analytics queries

Recommended for this design: PostgreSQL. The tables, queries and replication in this lesson are PostgreSQL: one primary with read replicas carries today's load, and sharding by the code's prefix is the path when one primary is outgrown. DynamoDB is the strongest alternative for AWS teams that want no database operations: the access pattern (a key-value lookup by short code) fits it well, and it partitions data automatically, so no manual sharding is needed.

DynamoDB schema (the alternative):

Table: url_mappings
  Partition Key: short_code (String)
  Attributes: original_url (String), created_at (Number), is_active (Boolean)
  Read Capacity: On-demand (auto-scales)
  Write Capacity: On-demand

How do we design the caching layer for 90%+ hit rates?

Level: Senior

Caching is the single most impactful design decision for a URL shortener. With a 100:1 read-write ratio, the difference between an 80% and a 95% cache hit rate is 20% vs 5% of redirects reaching the database: four times the database load.

Traffic Distribution: Zipf's Law

URL access follows a Zipfian distribution — a small fraction of URLs receive the vast majority of clicks. A typical shape:

  • Top 1% of URLs → ~50% of all clicks
  • Top 10% of URLs → ~90% of all clicks
  • Bottom 50% of URLs → ~1% of clicks (many are clicked only once)

This is excellent for caching: a small cache capturing the top URLs handles most traffic.

Cache Sizing

Cache SizeURLs CachedEstimated Hit RateDB QPS (at 1,160 total)
100 MB~200K URLs~75%290
500 MB~1M URLs~88%139
1 GB~2M URLs~93%81
5 GB~10M URLs~98%23

At 1 GB (easily fits in a single Redis instance), we achieve ~93% hit rate, leaving only ~80 QPS hitting the database. This is well within a single database node's capacity.

Caching Strategies Compared

StrategyHow It WorksProsCons
Cache-asideApp checks cache → miss → app queries DB → app writes cacheSimple, app controls logicCache miss requires 2 round trips
Read-throughApp reads cache → miss → cache auto-fetches from DBTransparent to app, fewer code pathsRequires cache library support
Write-throughOn URL creation, write to both DB and cacheCache is always warm for new URLsAdds latency to write path
Write-behindOn URL creation, write to cache first, async to DBLowest write latencyRisk of data loss if cache crashes

Recommended: Read-through + Write-through hybrid.

  • Write path: When a new URL is created, write to both DB and cache (write-through). This ensures newly created URLs are immediately resolvable without a cache miss.
  • Read path: On redirect, read from cache. On miss, the cache auto-fetches from DB (read-through).

Cache Stampede and Thundering Herd Prevention

Cache stampede: A popular URL's cache entry expires. 1,000 concurrent requests all experience a cache miss simultaneously. All 1,000 hit the database for the same URL. The database is overwhelmed.

Solution: Request coalescing (singleflight pattern)

import asyncio

class CoalescingCache:
    def __init__(self, redis_client, db_client):
        self.redis = redis_client
        self.db = db_client
        self._inflight = {}  # short_code → Future

    async def get(self, short_code: str) -> str:
        # Check cache first
        result = await self.redis.get(f"url:{short_code}")
        if result:
            return result

        # Coalesce concurrent misses for the same key
        if short_code in self._inflight:
            return await self._inflight[short_code]

        future = asyncio.get_running_loop().create_future()
        self._inflight[short_code] = future

        try:
            # Only ONE request hits the database
            url = await self.db.get(short_code)
            if url:
                await self.redis.set(f"url:{short_code}", url, ex=86400)
            future.set_result(url)
            return url
        except Exception as exc:
            future.set_exception(exc)  # Waiters get the error instead of hanging
            raise
        finally:
            del self._inflight[short_code]

With coalescing, 1,000 concurrent requests for the same expired key result in 1 database query instead of 1,000. The first request fetches from DB; the other 999 wait on the same Future.

Stale-while-revalidate: An alternative approach: serve the stale cached value immediately while refreshing in the background. This provides zero-latency cache misses at the cost of serving slightly stale data (acceptable for URL mappings that never change).

Multi-Tier Caching

For extreme scale (10B+ redirects/day), add a local in-process cache layer:

Tier 1: In-process LRU cache (each Redirect Handler instance)

  • Size: 100 MB per instance (~200K URLs)
  • Latency: < 0.1 ms (no network call)
  • Hit rate: ~60% (top URLs accessed by this specific instance)

Tier 2: Redis cluster (shared)

  • Size: 1-5 GB
  • Latency: ~1 ms
  • Hit rate: ~33% of all requests (93% cumulative with Tier 1)

Tier 3: Database

  • Latency: ~5-10 ms
  • Only ~7% of requests reach this tier
async def resolve_url(short_code: str) -> str:
    # Tier 1: In-process cache (no network)
    url = local_cache.get(short_code)
    if url:
        return url

    # Tier 2: Redis (1ms network)
    url = await redis.get(f"url:{short_code}")
    if url:
        local_cache.put(short_code, url)
        return url

    # Tier 3: Database (5-10ms)
    url = await db.get_url(short_code)
    if url:
        await redis.set(f"url:{short_code}", url, ex=86400)
        local_cache.put(short_code, url)
    return url

Should we use 301 or 302 redirects? What are the trade-offs?

Level: Mid-level

The redirect status code determines browser behavior, caching semantics, and analytics accuracy. This seemingly small decision has significant downstream consequences.

Status CodeNameBrowser BehaviorCache-Control
301Moved PermanentlyBrowser caches redirect forever; future visits bypass serverCached by default
302FoundBrowser does NOT cache redirect; every visit hits serverNot cached by default
307Temporary RedirectLike 302 but preserves HTTP method (POST stays POST)Not cached
308Permanent RedirectLike 301 but preserves HTTP methodCached

Impact Analysis

Dimension301 (Permanent)302 (Temporary)
Server loadLower — browser caches, fewer requests hit serverHigher — every click hits server
Analytics accuracyBroken — cached redirects bypass server entirely, clicks go uncountedAccurate — every click passes through server
URL deactivationBroken — browser uses cached redirect even after deactivationWorks — server can return 410 Gone
SEOSearch engines index the destinationSearch engines may keep indexing the short URL; link value still passes (Google: 3xx redirects don't lose PageRank)
User latencyLower — cached redirect is instantSlightly higher — server round-trip required

When to Use Each

Use 302 (recommended for most URL shorteners):

  • You need click analytics (the primary business model for many shorteners)
  • You need ability to deactivate malicious URLs
  • You want to A/B test destination URLs
  • The redirect is for external/marketing links

Use 301 for specific cases:

  • Internal tool links where analytics don't matter
  • Links embedded in documentation that will never change
  • When minimizing server load is critical and analytics are unnecessary

Hybrid Approach

A middle ground: use 302 with a Cache-Control header that allows short-term browser caching:

HTTP/1.1 302 Found
Location: https://example.com/destination
Cache-Control: private, max-age=300

This tells the browser: "Reuse this redirect for 5 minutes, then check back with us." Repeat clicks inside that window never reach the server, so you count at most one click per user per 5 minutes, and a deactivated link keeps working for that user until the cached copy expires. The longer the max-age, the bigger both effects: at max-age=86400, you count at most one click per user per day.

How do we prevent abuse and detect malicious URLs?

Level: Senior

URL shorteners are prime targets for abuse. Attackers use them to disguise phishing URLs, distribute malware, and bypass email security filters. If security vendors blocklist your domain, legitimate users lose access — a business-critical failure.

Defense Layers

Layer 1: Pre-creation scanning (synchronous)

Before storing a URL, check it against known malicious URL databases:

import aiohttp

SAFE_BROWSING_API = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key=" + SAFE_BROWSING_KEY

async def check_url_safety(url: str) -> dict:
    """Check URL against Google Safe Browsing API."""
    payload = {
        "client": {"clientId": "short.ly", "clientVersion": "1.0"},
        "threatInfo": {
            "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE"],
            "platformTypes": ["ANY_PLATFORM"],
            "threatEntryTypes": ["URL"],
            "threatEntries": [{"url": url}]
        }
    }
    async with aiohttp.ClientSession() as session:
        async with session.post(SAFE_BROWSING_API, json=payload) as resp:
            result = await resp.json()
            if result.get("matches"):
                return {"safe": False, "threats": result["matches"]}
            return {"safe": True}

async def shorten_url(long_url: str, client_ip: str) -> str:
    # Step 1: Rate limit by IP first: it is cheap, and it stops bulk creation of phishing URLs
    if not rate_limiter.allow(client_ip, limit=100, window=3600):
        raise RateLimitExceeded("Too many URLs created")

    # Step 2: Check safety BEFORE creating the short URL
    safety = await check_url_safety(long_url)
    if not safety["safe"]:
        raise MaliciousURLError("URL flagged by Safe Browsing API")

    # Step 3: Create short URL
    return create_short_url(long_url)

Layer 2: Post-creation batch scanning (asynchronous)

URLs can become malicious after creation (e.g., destination changes, new threat intelligence). A background scanner rechecks URLs periodically:

Scanning StrategyCheck FrequencyCoverageCost
New URLs (< 24h old)Every 4 hoursFocus on fresh, unverified URLsLow
Most-clicked URLsEvery 12 hoursHigh-impact URLsLow
Random sample (1%)DailyCatch long-tail abuseLow
Reported URLsImmediatelyUser-triggered investigationReactive

Layer 3: Enumeration prevention

Short codes like a00001, a00002, a00003 are sequential and predictable. An attacker can enumerate all URLs by incrementing the code. Mitigations:

  • Randomize sequence within machine: Instead of strictly incrementing, use a permutation function that maps sequential integers to non-sequential outputs
  • Rate limit redirect requests: Detect enumeration patterns (sequential codes from same IP)
  • Don't expose creation-order information: Ensure short codes don't reveal when they were created

URL Deactivation and Takedown Process

When a malicious URL is detected:

  1. Immediate: Set is_active = FALSE in the database
  2. Cache invalidation: In every region's Redis, delete the cache entry (DEL url:{short_code}) and set a permanent block marker; tell every Redirect Handler to drop the link from its in-process cache (a pub/sub message)
  3. Redirect behavior change: Return 410 Gone with a warning page instead of redirecting
  4. Notification: Alert the URL creator (if authenticated) and the reporter (if user-reported)
async def deactivate_url(short_code: str, reason: str):
    await db.execute(
        "UPDATE url_mappings SET is_active = FALSE WHERE short_code = %s",
        short_code
    )
    await redis.delete(f"url:{short_code}")
    await redis.set(f"deactivated:{short_code}", reason)  # No expiry: a deactivated link stays blocked
    await audit_log.record(short_code, "DEACTIVATED", reason)

async def handle_redirect(short_code: str):
    # Check deactivation first (cached check)
    deactivation_reason = await redis.get(f"deactivated:{short_code}")
    if deactivation_reason:
        return render_warning_page(short_code, deactivation_reason)

    url = await resolve_url(short_code)
    if url:
        await record_click(short_code)  # Async analytics
        return redirect(url, status=302)
    return not_found()

The block holds even when caches are rebuilt: every database lookup reads only active rows (WHERE short_code = %s AND is_active), so a cold cache cannot bring a deactivated link back.

Time to deactivation target: < 5 minutes from detection to global cache purge. This limits the exposure window for phishing campaigns.

How do we deploy across regions for global availability?

Level: Senior

A user in California creates a short URL. Seconds later, someone in Tokyo clicks it. The URL must resolve globally with minimal latency. This requires multi-region deployment with careful consistency trade-offs.

Multi-region deployment. DNS GEO-ROUTING sends each user to the nearest region. US-WEST holds the PRIMARY: a CREATOR in California sends POST (1) to its SERVERS (shorten, redirect), which INSERT into the PRIMARY (2), all writes, and SET the link in the local REDIS at once. The primary replicates (3), async, 100–300 ms, to each region's REPLICA directly: one arrow to EU-WEST and one to AP-EAST. EU-WEST and AP-EAST each have REDIRECT handlers, a REDIS cache and a REPLICA. A VISITOR in Tokyo sends GET /a1B2c3 (4) to AP-EAST; in the first ~300 ms a miss there reads the primary (5), +100–200 ms. A timeline: t = 0, created, US-WEST has it; ~300 ms, every replica has it; between them other regions may miss it. A code found nowhere is cached as "" for 5 s. The title reads: one primary, a replica per region.

The Consistency Challenge

A newly created URL must be globally resolvable within seconds. But cross-region replication takes 100-300 ms. During this window, a request to the AP-EAST region returns 404 for a URL that exists in US-WEST.

Solution: Read-after-write consistency for creators

async def shorten_url(long_url: str) -> dict:
    short_code = generate_id()

    # Write to primary (US-WEST)
    await primary_db.insert(short_code, long_url)

    # Write-through to LOCAL cache (immediate availability in creation region)
    await local_redis.set(f"url:{short_code}", long_url, ex=86400)

    # Return short URL — creator can immediately share it
    return {"shortUrl": f"https://short.ly/{short_code}"}

The creator's region has the URL immediately in cache. Other regions receive it within 300 ms via replication. For the rare case where someone clicks a URL within 300 ms of creation from a different region:

Fallback: Cross-region read on miss

async def resolve_url(short_code: str) -> str:
    # 1. Check local cache ("" marks a code recently found nowhere)
    cached = await local_redis.get(f"url:{short_code}")
    if cached is not None:
        return cached or None

    # 2. Check local DB replica
    url = await local_db.get(short_code)
    if url:
        await local_redis.set(f"url:{short_code}", url, ex=86400)
        return url

    # 3. Fallback: check primary region (adds 100-200ms latency)
    url = await primary_db.get(short_code)
    if url:
        await local_redis.set(f"url:{short_code}", url, ex=86400)
        return url

    # 4. Not found anywhere: remember that for 5 seconds (our global visibility target),
    #    so repeated lookups of a missing code stay local
    await local_redis.set(f"url:{short_code}", "", ex=5)
    return None

The cross-region fallback adds 100-200 ms. For real links it triggers only in the first ~300 ms after creation, before the link reaches the local replica. But every code that does not exist at all — typos, bots, enumeration attempts — also falls through to the primary region, which is why step 4 caches the miss. It keeps that answer for only 5 seconds: codes are sequential, so a code probed just before it is created must still become visible within our 5-second target. Probing many different codes is the rate limiter's job (see the abuse deep dive).

Regional Cache Warming

For high-profile URLs (e.g., a marketing campaign shared on social media), pre-warm caches in all regions at creation time:

StrategyWhen to UseLatency Impact
Async replication onlyDefault (99% of URLs)100-300ms replication lag
Cross-region cache writeURLs created via API with priority: high flag50-100ms added to write path
Cross-region read fallbackCache miss for very new URLs100-200ms for affected reads

How do we handle URL expiration and storage growth?

Level: Mid-level

At 1M URLs/day for 5 years, we accumulate 1.825B records (~1 TB). Without cleanup, storage grows indefinitely. But deleting URLs is dangerous — a "dead link" is worse than a "slightly slow link."

Our requirements say a short URL works forever, and expiration was out of scope. At about 1 TB, keeping every link is affordable, and the storage tiering below keeps it cheap. So expiring links is a product decision (for example for free-tier or never-used links), not a storage necessity. If the product decides to expire links, this is how to do it safely.

Expiration Policy

URL CategoryRetentionRationale
Active URLs (clicked in last 90 days)ForeverStill in use, never expire
Dormant URLs (not clicked in 90 days)5 years from creationMight be embedded in old documents
Orphan URLs (never clicked, > 1 year old)1 year from creationLikely created by bots or forgotten
Deactivated URLs (malicious)Keep record forever, redirect disabledPrevent short code reuse

TTL-Based Cleanup

def cleanup_expired_urls():
    """Runs daily as a scheduled batch job."""
    # Never delete — archive and soft-delete
    expired = db.query("""
        SELECT m.short_code FROM url_mappings m
        LEFT JOIN url_analytics a ON a.short_code = m.short_code
        WHERE m.is_active = TRUE
          AND (
                -- Dormant: older than 5 years, not clicked in the last 90 days
                (m.created_at < NOW() - INTERVAL '5 years'
                 AND (a.last_accessed IS NULL OR a.last_accessed < NOW() - INTERVAL '90 days'))
                -- Orphan: older than 1 year, never clicked (no analytics row yet)
             OR (m.created_at < NOW() - INTERVAL '1 year' AND a.short_code IS NULL)
          )
    """)

    for batch in chunk(expired, 1000):
        # Archive to cold storage (S3/Glacier)
        archive_to_cold_storage(batch)
        # Soft-delete
        db.execute(
            "UPDATE url_mappings SET is_active = FALSE WHERE short_code IN %s",
            batch
        )
        # Invalidate cache (and every in-process cache, as for a deactivated link)
        redis.delete(*[f"url:{code}" for code in batch])

Storage Tiering

TierDataStorageCost
Hot (0-30 days)Recent URLs + active cacheNVMe SSD + Redis$$$
Warm (30 days - 1 year)Most URLsSSD$$
Cold (1-5 years)Dormant, rarely accessedHDD or S3$
Archive (5+ years)Historical recordsS3 Glacier¢

Key principle: Never delete a short code that might be embedded somewhere. Soft-delete, archive, and serve a "this link has expired" page instead of a 404: when a redirect finds no active link, check whether the code exists with is_active = FALSE, and if it does, show that page.