Dinesh/ Blog
systems

Design a URL Shortener — the Classic Warm-Up

Design a shortener around authoritative mappings, unique allocation, explicit redirect caching and takedown policies, with executable failure models.

23 min readChecking device speech…
Lesson preparation & details

Level: advanced

Designing for scale · Lesson 17 of 20 ↗
Loading this browser’s progress…

By the end, you should be able to

  • Compare unique short-code allocation strategies with collision and durability analysis
  • Design redirect, cache, disablement and analytics contracts together
  • Test mapping insertion, collision retries, cache failure, range boundaries and expiry

Bring with you

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 19 sections

A URL shortener is a small mapping service with several independent correctness contracts: allocating unique names, durably recording destinations, deciding which redirects may be cached, disabling abusive links and accounting for clicks. A short code is not a security boundary, and a fast cache is not a substitute for an authoritative mapping.

Optional preparation

Use the original 30-minute preparation suggestion as a selection budget, not the sum of every pointer. Video titles are historical finding labels; unseen content, creator identity, advertised runtime and external availability are unverified. These pointers are not additional deployment evidence.

Requirements and the two request paths

The cloakroom analogy is useful: a short ticket refers to a stored object. But an internet shortener also needs expiry, takedown, owner permissions, custom aliases and recovery from duplicated requests. Decide these before choosing ID generation or cache lifetime.

For this worked design, assume:

  • Authenticated owners create mappings; public readers follow enabled links.
  • Destinations use approved HTTP/HTTPS syntax; the redirect service does not fetch them.
  • Owners can disable links, and a defined takedown policy bounds cache staleness.
  • Custom aliases are first-writer-wins under a unique constraint; namespace reservations and authorization are product rules.
  • Create acknowledgement means the mapping is durably accepted under the chosen database/failure model.
  • Analytics is best effort in the executable model; a billing-grade product would need a different durable capture contract.
POST /shorten {url, optional_alias}, authenticated owner + idempotency key
  -> validate -> authorize -> atomic insert -> return canonical short URL
GET /{code}
  -> validate code -> resolve allowed mapping -> 302 + Location + cache policy
POST /{code}/disable, authenticated owner or authorized abuse operator
  -> record disabled state -> propagate/invalidate according to takedown contract
GET /{code}/stats, authorized owner
  -> report metric scope, delay and completeness, not “every human click”

Use a configured canonical host when constructing links, not an untrusted Host/forwarded header. Constrain code length/characters and input/body size before expensive work. A 2,048-character URL limit is an example product cap, not an HTTP standard or proof that a 4 KB URL itself causes denial of service.

Capacity, code space and the birthday distinction

Assume 100 million new links per 30-day month, 100 redirect requests per creation and 500 logical stored bytes per link. These are design inputs, not Bitly/TinyURL measurements.

QuantityCalculationMeaning
Average creates/s100M / (30 × 86,400) = 38.58About 40/s, not peak
Average redirects/s100 × 38.58 = 3,858.02About 4k/s
Illustrative 5× peaks192.9 creates/s, 19,290 redirects/sAssumed burst model
Five-year logical storage100M × 500 × 12 × 5 = 3 TBBefore indexes, replicas, history and analytics
Seven-character Base62 space62^7 = 3,521,614,606,208Fixed-length namespace cardinality

Three TB does not prove one node is sufficient, nor require sharding “anyway.” Evaluate indexes, working set, durability, operations, measured read/write latency and future growth. Plan an evolvable partition key without prematurely adding a distributed control plane.

For independent uniform candidate codes from a space MM, the approximate probability of at least one collision among nn draws is:

p≈1−exp⁡(−n(n−1)2M).p \approx 1-\exp\left(-\frac{n(n-1)}{2M}\right).

The probability that the next draw hits one of kk currently occupied distinct codes is k/Mk/M. These are different questions. A high historical probability of at least one collision does not mean a bounded retry allocator is unusable. With seven Base62 characters and two million draws, the birthday approximation is about 43.3%; with eight characters and one million draws it is about 0.229%. At 100 million occupied seven-character codes, next-draw collision probability is only about 0.00284% under uniform sampling. The database must still enforce uniqueness atomically.

import math
from fractions import Fraction
 
def birthday(n, length):
    if n < 0 or length < 1:
        raise ValueError("nonnegative n and positive length")
    space = 62 ** length
    if n > space:
        return 1.0
    return -math.expm1(-n*(n-1)/(2*space))
 
assert 62**7 == 3521614606208
assert 0.00228 < birthday(1_000_000, 8) < 0.00230
assert 0.43 < birthday(2_000_000, 7) < 0.44
assert Fraction(100_000_000, 62**7) < Fraction(3, 100_000)
assert 100_000_000 * 500 * 12 * 5 == 3_000_000_000_000
for length in (6, 7, 8, 10):
    print(length, [round(birthday(n, length), 6) for n in (10**6, 10**8, 10**9)])

This exact CPU program checks probability arithmetic, not a random-number generator's security. Hex digest characters are Base16, not Base62; slicing seven hexadecimal characters gives 16^7 possibilities, dramatically fewer than 62^7.

Four ID strategies and their actual tradeoffs

Counter plus Base62

Base conversion maps each nonnegative integer to one string under a declared alphabet. It is an encoding, not the allocator. A database sequence can allocate integers efficiently; it can have gaps on rollbacks and crashes. PostgreSQL sequence documentation explicitly warns that sequence values are not gapless. At this exercise's write rate a single database-backed allocator may be adequate—measure rather than declaring it must be distributed.

Using ascii_letters + digits gives alphabet abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789. An example encoded value depends on that ordering; the original hardcoded 1LY7VK was not proven to match the Python alphabet. Define the alphabet once and verify encode/decode round trips.

Random candidate plus unique insert

Generate a sufficiently long CSPRNG code, attempt an atomic unique insert, retry on collision with a fixed limit, and return only after a successful commit. A preliminary SELECT is neither necessary nor sufficient: concurrent creators can both see “missing.” PostgreSQL INSERT supports ON CONFLICT ... DO NOTHING and RETURNING; no returned row means no mapping was created. It is not idempotency to ignore a conflicting code and return a different destination from cache.

Truncated deterministic hash

Hashing a URL can help deduplicate a deliberately canonicalized identity, but truncation reintroduces collisions and stateful resolution. Canonicalization itself can change semantics if done carelessly. Determinism lets someone compute a candidate for a guessed URL; checking whether it exists still depends on service access. Owner-specific links/custom aliases remain possible with namespacing and explicit data models, so hashing does not make them intrinsically impossible. Choose it only if deduplication semantics justify the complications.

Range allocation and pre-generated pools

A durable allocator can reserve disjoint integer ranges; each process allocates locally under a lock. Crashes waste unused IDs, and recovery must never reuse an allocated range. Redis INCRBY is atomic at a running server but allocator durability/failover policy determines whether acknowledged ranges survive. Do not use an evictable ordinary cache key as the only uniqueness authority. Time/worker/sequence schemes such as Snowflake are a different family, not identical to range allocation; they introduce clock/worker-ID rules.

A pre-generated pool shifts candidate creation out of the request path but still needs a durable unique claim protocol, refill/low-watermark monitoring and crash recovery. Shuffling alone does not make a small namespace unguessable. Sequential codes expose enumeration, but encrypted/permuted counters and random codes are not authorization for private destinations. Use proper access control or adequately designed capability semantics where privacy is required; do not invent a format-preserving cipher in application code.

Range allocation and pre-generated pools
Figure text equivalent — labels and relationships
  • Node “Allocate counter or random candidate” (square).
  • Node “Atomic unique mapping insert” (square).
  • Node “Retry bounded candidate or return alias conflict” (square).
  • Node “Return canonical short URL” (square).
  • “Allocate counter or random candidate” leads to “Atomic unique mapping insert”. Line: normal; ends: arrow point.
  • “Atomic unique mapping insert” leads to “Retry bounded candidate or return alias conflict”: conflict. Line: normal; ends: arrow point.
  • “Retry bounded candidate or return alias conflict” leads to “Allocate counter or random candidate”. Line: normal; ends: arrow point.
  • “Atomic unique mapping insert” leads to “Return canonical short URL”: committed. Line: normal; ends: arrow point.

Redirect status, cache policy and revocation

RFC 9110 distinguishes permanent 301/308 from temporary 302/307 redirects. 307/308 preserve the method; 301/302 permit POST-to-GET behavior for historical compatibility. This shortener accepts GET/HEAD redirects; do not silently redirect state-changing requests with ambiguous method semantics.

RFC 9111 defines caching separately. A 301 is heuristically cacheable, not literally guaranteed cached forever. A 302 can be cached with explicit freshness; it does not guarantee every visit reaches the server. Long client caching can bypass both analytics and a later takedown even if the origin mapping changes. You cannot reliably recall a response already cached in a browser you do not control.

For the strict local model, use 302, Location and Cache-Control: no-store. That asks conforming caches not to store the response; it does not prove analytics sees every human click or prevent clients from copying destinations. A product willing to accept up to 60 seconds of shared-cache staleness might use explicit bounded shared freshness while keeping browser freshness zero, but must verify actual CDN/cache behavior, authorization and invalidation. Do not claim both instant disablement and unbounded edge/browser caching.

Cache hierarchy and latency budgets

Cache hierarchy and latency budgets
Figure text equivalent — labels and relationships
  • Node “Reader” (square).
  • Node “Policy-aware edge cache” (square).
  • Node “Redirect service” (square).
  • Node “Bounded regional cache” (square).
  • Node “Mapping and enabled state” (cylinder).
  • Node “Authenticated creator” (square).
  • Node “Analytics pipeline” (square).
  • Node “Aggregates with completeness metadata” (square).
  • “Reader” leads to “Policy-aware edge cache”. Line: normal; ends: arrow point.
  • “Policy-aware edge cache” leads to “Redirect service”: miss. Line: normal; ends: arrow point.
  • “Redirect service” leads to “Bounded regional cache”. Line: normal; ends: arrow point.
  • “Redirect service” leads to “Mapping and enabled state”: cache miss or authoritative policy check. Line: normal; ends: arrow point.
  • “Authenticated creator” leads to “Redirect service”. Line: normal; ends: arrow point.
  • “Redirect service” leads to “Analytics pipeline”: best effort or durable contract. Line: normal; ends: arrow point.
  • “Analytics pipeline” leads to “Aggregates with completeness metadata”. Line: normal; ends: arrow point.

The service, not Redis, performs the database fallback in a typical cache-aside design. Partition latency measurements by path and geography. A hypothetical distribution of 99% edge hits, 0.9% regional hits and 0.1% DB lookups sums to 100%; “1% regional plus another 0.1% DB” needs conditional-denominator clarification. If 90% of edge misses hit regional cache, the DB path is 1% × 10% = 0.1% overall.

Example assumed path latencies of 10/40/200 ms yield a weighted mean of 10.46 ms under those probabilities; they do not imply p99, because quantiles depend on distributions and conventions at the boundary. The full user journey also includes DNS, connection/TLS setup, the redirect response and fetching the destination. “Every redirect globally under 30 ms” cannot be guaranteed by naming a CDN.

Cloudflare's Workers KV consistency guidance describes caching/eventual visibility and limits around atomic operations. A KV-backed edge redirect is not automatically a globally atomic alias allocator or an instant takedown mechanism. Measure propagation and use the correct consistency authority for uniqueness/policy.

Stampedes, stale data and negative caching

Singleflight coalesces concurrent loads within its coordination scope. One process's singleflight does not guarantee one fleet-wide DB read; distributed coordination requires its own failure rules. Bound waiter count/time and release them on loader failure. Add jitter/admission and concurrency limits so cache recovery does not overload the database.

RFC 5861 describes stale-while-revalidate and stale-if-error windows. Stale serving is safe only under the mapping's freshness/security contract; never knowingly serve a disabled phishing link just because the database is unavailable. An expired Redis entry is removed; serving stale requires separately retained stale data and an application policy, not simply reading past a deleted TTL. Choose eviction from workload and durability roles: Redis eviction guidance does not say allkeys-lru is always right or that noeviction itself equals an OOM kill.

Negative caching helps repeated missing codes but can be flooded with distinct random candidates. Bound admission, TTL, key size and scan rate; distinguish missing, expired, disabled and temporarily unavailable states without leaking private data. When a newly created alias was negatively cached, invalidate or limit staleness deliberately.

Executed allocation, mapping and cache-failure model

The exact program uses in-memory SQLite and a simulated cache/analytics sink. It demonstrates transaction/collision/expiry behavior without running a Flask server, Redis, PostgreSQL, network requests or paid APIs. A complete in-process Flask adapter at the end verifies create and redirect response semantics with a trusted synthetic owner. The database is authoritative for enabled/expiry state on every read, so the tiny model deliberately pays a DB policy lookup even on a payload-cache hit. It does not advertise CDN-scale performance or global instantaneous revocation.

import sqlite3
import string
from urllib.parse import urlsplit
 
ALPHABET = string.ascii_letters + string.digits
 
def encode_base62(n):
    if type(n) is not int or n < 0:
        raise ValueError("nonnegative integer required")
    if n == 0:
        return ALPHABET[0]
    out = []
    while n:
        n, remainder = divmod(n, 62)
        out.append(ALPHABET[remainder])
    return "".join(reversed(out))
 
def decode_base62(code):
    if not code or any(c not in ALPHABET for c in code):
        raise ValueError("invalid code")
    n = 0
    for c in code:
        n = n*62 + ALPHABET.index(c)
    return n
 
def valid_url(url):
    if not isinstance(url, str) or not 1 <= len(url) <= 2048:
        return False
    if any(ord(c) < 33 or ord(c) == 127 or c == "\\" for c in url):
        return False
    try:
        p = urlsplit(url)
        return (p.scheme in {"http", "https"} and bool(p.hostname)
                and p.username is None and p.password is None
                and (p.port is None or 1 <= p.port <= 65535))
    except ValueError:
        return False
 
class Cache:
    def __init__(self):
        self.values, self.failed = {}, False
    def get(self, key):
        if self.failed:
            raise ConnectionError("synthetic cache failure")
        return self.values.get(key)
    def put(self, key, value):
        if self.failed:
            raise ConnectionError("synthetic cache failure")
        self.values[key] = value
 
class Shortener:
    def __init__(self):
        self.db = sqlite3.connect(":memory:")
        self.db.executescript("""
        CREATE TABLE urls(code TEXT PRIMARY KEY, url TEXT NOT NULL,
          owner TEXT NOT NULL, expires INTEGER NOT NULL, disabled INTEGER NOT NULL DEFAULT 0);
        CREATE TABLE requests(owner TEXT NOT NULL, idem TEXT NOT NULL, url TEXT NOT NULL,
          code TEXT NOT NULL, PRIMARY KEY(owner, idem));
        """)
        self.cache = Cache()
        self.events, self.dropped, self.event_capacity = [], 0, 2
 
    def create(self, owner, url, idem, candidates, now=0, ttl=3600, alias=None):
        if not owner or not idem or not valid_url(url) or ttl <= 0:
            raise ValueError("invalid create request")
        with self.db:
            old = self.db.execute("SELECT url, code FROM requests WHERE owner=? AND idem=?",
                                  (owner, idem)).fetchone()
            if old:
                if old[0] != url or alias is not None and old[1] != alias:
                    raise ValueError("idempotency key reused with different input")
                return old[1]
            for attempt in range(8):
                code = alias if alias is not None else next(candidates)
                if not isinstance(code, str) or not 1 <= len(code) <= 16 or any(c not in ALPHABET for c in code):
                    raise ValueError("invalid code")
                result = self.db.execute(
                    "INSERT INTO urls(code,url,owner,expires) VALUES (?,?,?,?) ON CONFLICT(code) DO NOTHING",
                    (code, url, owner, now+ttl))
                if result.rowcount == 1:
                    self.db.execute("INSERT INTO requests VALUES (?,?,?,?)", (owner, idem, url, code))
                    break
                if alias is not None:
                    raise ValueError("alias conflict")
            else:
                raise RuntimeError("collision retry budget exhausted")
        # Only committed authoritative mappings may populate the cache.
        try:
            self.cache.put(code, url)
        except ConnectionError:
            pass  # cache is an optimization, not create authority
        return code
 
    def disable(self, owner, code):
        with self.db:
            changed = self.db.execute("UPDATE urls SET disabled=1 WHERE code=? AND owner=?",
                                      (code, owner)).rowcount
        return changed == 1
 
    def follow(self, code, now):
        if not isinstance(code, str) or not 1 <= len(code) <= 16 or any(c not in ALPHABET for c in code):
            return 404, {}, None
        row = self.db.execute("SELECT url, expires, disabled FROM urls WHERE code=?", (code,)).fetchone()
        if row is None:
            return 404, {}, None
        if row[2] or now >= row[1]:
            return 410, {"Cache-Control": "no-store"}, None
        try:
            cached = self.cache.get(code)
        except ConnectionError:
            cached = None
        destination = row[0]  # immutable mapping; authoritative row wins over corrupt cache
        if cached != destination:
            try:
                self.cache.put(code, destination)
            except ConnectionError:
                pass
        if len(self.events) < self.event_capacity:
            self.events.append(code)
        else:
            self.dropped += 1
        return 302, {"Location": destination, "Cache-Control": "no-store"}, destination
 
if __name__ == "__main__":
    service = Shortener()
    a = service.create("alice", "https://example.test/a", "request-1", iter(["x"]))
    b = service.create("bob", "https://example.test/b", "request-2", iter(["x", "y"]))
    assert (a, b) == ("x", "y")
    assert service.create("alice", "https://example.test/a", "request-1", iter([])) == "x"
    service.cache.failed = True
    assert service.follow("x", 1)[2] == "https://example.test/a"
    assert service.follow("y", 1)[2] == "https://example.test/b"
    assert service.follow("y", 1)[0] == 302 and service.dropped == 1
    assert not service.disable("alice", "y")
    assert service.disable("alice", "x")
    assert service.follow("x", 2)[0] == 410
    assert service.follow("y", 3600)[0] == 410
    for n in (0, 1, 61, 62, 1234567890, 62**7-1):
        assert decode_base62(encode_base62(n)) == n
    print("collision, idempotency, authority, cache failure, expiry and analytics bounds: PASS")
 
# HTTP redirect adapter for an already established trusted owner context.
# No network server; authentication integration is deliberately not a fake header.
from flask import Flask, request, jsonify, redirect
 
def make_shortener_app(service, trusted_owner, candidate_factory, clock):
    app = Flask(__name__)
    app.config["MAX_CONTENT_LENGTH"] = 4096
    canonical = "https://short.example.test/"
 
    @app.post("/shorten")
    def shorten_http():
        owner = trusted_owner()  # production: authenticated, authorized request context
        if owner is None:
            return jsonify(error="unauthorized"), 401
        body = request.get_json(silent=True)
        if not isinstance(body, dict):
            return jsonify(error="invalid body"), 400
        try:
            code = service.create(owner, body.get("url"), request.headers.get("Idempotency-Key"),
                                  candidate_factory(), clock(), alias=body.get("alias"))
        except ValueError as error:
            status = 409 if "conflict" in str(error) or "reused" in str(error) else 400
            return jsonify(error="create rejected"), status
        except (RuntimeError, StopIteration):
            return jsonify(error="allocation unavailable"), 503
        response = jsonify(code=code, short=canonical+code)
        response.headers["Cache-Control"] = "no-store"
        return response, 201
 
    @app.get("/<code>")
    def follow_http(code):
        status, headers, destination = service.follow(code, clock())
        if status != 302:
            response = jsonify(error="link unavailable")
            response.status_code = status
        else:
            response = redirect(destination, code=302)
        response.headers.update(headers)
        return response
    return app
 
if __name__ == "__main__":
    http_service = Shortener()
    http_app = make_shortener_app(http_service, lambda: "fixture-owner", lambda: iter(["Q"]), lambda: 0)
    with http_app.test_client() as client:
        reply = client.post("/shorten", json={"url": "https://example.test/destination"},
                            headers={"Idempotency-Key": "fixture-request", "Host": "untrusted.example"})
        assert reply.status_code == 201
        assert reply.get_json()["short"] == "https://short.example.test/Q"
        response = client.get("/Q", follow_redirects=False)
        assert response.status_code == 302
        assert response.headers["Location"] == "https://example.test/destination"
        assert response.headers["Cache-Control"] == "no-store"
        assert client.get("/missing").status_code == 404
    print("Flask test-client create, canonical host and redirect headers: PASS")

URL syntax validation here is not an anti-phishing verdict, full browser-URL canonicalizer or SSRF defense. The service returns a Location header without fetching a destination; a preview/scanning subsystem would introduce a new outbound trust boundary requiring separate controls. In a real create API bind idempotency to all meaningful request fields, including expiry/policy, and define retention for replay records. The model binds owner, URL and requested alias; TTL replay semantics are fixed by the original request's stored result rather than extended on retry.

Boundaries to carry into a real adapter

The preserved fixture does not implement every endpoint in the design contract: HTTP create/follow are wired, disable is a local owner-checked method, and analytics is a bounded local list, not an authorized stats API. Its missing-code 404 responses have no explicit cache directive. RFC 9110 permits heuristic caching of 404, so a real adapter must choose a bounded negative-cache policy or no-store; otherwise creating an alias after a cached miss need not make it immediately visible. The 302 and 410 branches already use no-store in this fixture.

Authorization and disablement also need an ordering contract. A redirect whose policy lookup precedes a concurrent disable commit can still return the previously allowed destination. State whether takedown applies to lookups begun after commit, to completed responses within a bound, or to a stronger serialized boundary. No origin policy can retract a destination already disclosed. Authentication, request-field typing, tenant/owner isolation, transactional concurrency, body-byte limits, query timeouts and real cache capacity are adapter obligations; the single-process test is not a hardened public server.

PostgreSQL/Redis adapter reference

For the production-shaped alternative, use a database transaction and connection pool, not one shared thread-unsafe connection. A candidate insert must return a row before you warm Redis or return success:

INSERT INTO urls (code, long_url)
VALUES ($1, $2)
ON CONFLICT (code) DO NOTHING
RETURNING code, long_url;

$1/$2 are server-side prepared-statement placeholders; a Python driver such as psycopg uses its documented binding syntax instead. The unique constraint arbitrates concurrent writers. On no returned row, bounded retry a new random candidate or return an alias conflict. Persist the idempotency result in the same transaction as the mapping. If the DB commit succeeds but cache fill fails, the mapping is still accepted; if the response is lost, replay the same idempotency key rather than creating an accidental duplicate.

Catch specific cache connectivity/timeout errors with a bounded fallback, use realistic query/connection timeouts and never swallow a database failure as a successful creation. A Redis outage in the original uncaught r.get() would raise immediately; it did not automatically hit PostgreSQL. If DB is down, only cache entries with a permitted freshness/policy contract may serve; otherwise fail clearly. Do not kill real services as a tutorial test or use KEYS against a production keyspace to count analytics.

Complete range-allocation exercise

This exact local model uses exclusive upper bounds and a lock. The original inclusive-end comparison skipped one ID per range and omitted its threading import. The model's allocator is an in-process stand-in for a durable allocation transaction, not proof of distributed crash/failover safety.

import threading
 
class RangeAuthority:
    def __init__(self):
        self.next = 1
        self.lock = threading.Lock()
        self.calls = 0
    def reserve(self, size):
        if size <= 0:
            raise ValueError("positive range size")
        with self.lock:
            start = self.next
            self.next += size
            self.calls += 1
            return start, self.next  # [start, end)
 
class RangeClient:
    def __init__(self, authority, size=10000):
        self.authority, self.size = authority, size
        self.next, self.end = 0, 0
        self.lock = threading.Lock()
    def allocate(self):
        with self.lock:
            if self.next == self.end:
                self.next, self.end = self.authority.reserve(self.size)
            value = self.next
            self.next += 1
            return value
 
authority = RangeAuthority()
a, b = RangeClient(authority, 3), RangeClient(authority, 3)
assert [a.allocate(), b.allocate(), a.allocate(), b.allocate(), a.allocate(), b.allocate()] == [1, 4, 2, 5, 3, 6]
assert authority.calls == 2
assert a.allocate() == 7
print("disjoint complete half-open ranges: PASS")

For a real authority, atomically advance a persisted high-water mark before handing out a range, with the durability guarantees required for failover. A crashed process's unused suffix is abandoned, never handed out from a restored old snapshot. Range allocation reduces coordination calls by the block size in the ideal full-use case; it does not establish a particular QPS speedup. Twitter's archived Snowflake repository is a historical distributed-ID project, not evidence that Twitter and Instagram used this exact INCRBY code.

Partitioning, abuse and analytics

Hash partition by code because public redirect lookups have the code, not necessarily the owner or destination. Hashing can distribute keys, but request load can remain skewed by viral links; no near-perfect load balance is guaranteed. Owner partitioning may require a code-to-owner directory or a code carrying routing information—not inevitably scatter-gather. A code-based design is a good default for this access path, not a universal theorem that shard key must equal lookup key.

With hash(code) % N, changing N moves many keys. Logical partitions mapped to physical nodes allow more controlled movement; 1,024 is an example count, not a sacred value or a sourced claim about another company's deployment. Specify versioned routing, migration copy/catch-up, concurrent write handling, read fallback, cutover and rollback. A single-node design can retain this logical route plan for later measured need.

A viral link needs hot-key detection, CDN/regional cache policy, bounded coalescing, admission control and origin concurrency limits. A disabled link requires priority invalidation or a policy check even if that reduces hit rate. The original unverified “2016 Bitly OOM outage” and “30–40% malicious traffic everywhere” are not retained as facts. Use a synthetic incident: cache unavailable, DB queues grow, clients retry; cap waits/retries, preserve correctness, shed load and recover gradually rather than simply serving stale indefinitely.

Anti-abuse design includes account/source quotas, destination reputation with provider terms/coverage, rescanning when warranted, warning/interstitial flows, reporting with abuse-resistant adjudication, takedown audit/appeal and ownership checks. Automatic quarantine after N reports can itself be weaponized. Threat feeds have false positives, false negatives, licensing and freshness limits; this review used no paid or external scanning API. Custom alias squatting/reserved names and private-link enumeration are separate product threats.

Analytics needs a declared loss/duplication model. Best-effort local queueing can drop events on capacity or process failure; expose drop counts and label reports incomplete. Durable capture adds latency, storage or a local outbox/spool whose acceptance boundary must be acknowledged. At-least-once delivery needs event IDs/deduplication for exact accounting. Edge hits may never reach the origin, so origin counters alone are not complete click analytics. One billion events/day at an assumed 200 bytes/event is 200 GB/day before replication/indexes, not automatically a terabyte. Don't claim every Kafka publish costs 5–20 ms or multiplies redirect latency by a fixed factor.

Recall and worked answers

  1. Redirect path? Name DNS/connection, optional edge, service, cache, authoritative mapping/policy and Location response, then the destination fetch. Which hops execute depends on caching; analytics completeness must reflect bypasses.
  2. Hash truncation problems? Collisions and deterministic identity/canonicalization/privacy semantics. Use atomic unique insertion; hashes remain a possible deliberate design, not intrinsically doomed.
  3. 301 versus 302? Permanence and method semantics differ from caching. 302 can cache; 301 is not guaranteed forever. Choose status and explicit cache/revocation policy together.
  4. Five-year storage? Three decimal TB of logical 500-byte records under 100M/month; add overhead and measure before deciding to shard.
  5. Viral key? Coalesce within known scope, bound waits/fallback, cache only under safe freshness and enforce takedown. One-process singleflight is not one fleet-wide query.
  6. Why code partition? It matches public point lookups; alternatives need routing/directory design. Key uniformity does not eliminate traffic skew.
  7. Scale allocation? Reserve durable nonreused ranges; local locked allocation amortizes calls. Prove boundaries and crash recovery; don't confuse it with time/worker/sequence IDs.
  8. Async analytics? It decouples expensive processing, but lossless accepted capture has a durability cost. Explicitly choose best effort or durable accounting and handle duplicates/drops.
  9. Abuse stack? Threat-model quotas, reputation, rescanning, interstitials, reporting/adjudication and takedown. The malicious fraction is a measured local input, not a universal 30–40% fact.
  10. Concrete sharding tradeoff? Hash(code) into logical partitions gives directed lookups and controlled migration; owner partitioning may need a directory/routable code. Explain migration, hot-key and privacy consequences rather than declaring the other design impossible.

Final exercise: replay a creation after a lost response, force a candidate collision, fail the cache, disable a still-cached link, and fill the analytics queue. The exact local model keeps the authoritative mapping correct through those cases. A global release still needs real storage durability, CDN behavior, authorization, load and failure tests; those service gates were not run here.

Prerequisites and study route

Bring the requirements/capacity/HLD/deep-dive framework, cache freshness, HTTP redirects and database uniqueness. This smaller design prepares you for chat and feed systems with different ordering, fanout and delivery contracts; keep rate limiting and abuse controls in the design.

Revisit the framework, caching, sharding/partitioning and load balancing. Topic routes replace the original mismatched S070/S066 labels. Newsfeed extends fanout and hot-key reasoning. Rate limiting remains the original S140 extension topic, not a newly invented local destination: budget requests per account/source and separately cap expensive destination checks.

Next original-series stop: S128 · Design a Chat System — WebSockets, Delivery, Presence. The 6-Month Learning Plan remains the original hub. The original 130-session context suggested a 90-minute block through intuition, visual walkthrough, hands-on, failure scenarios and recall; use it as pacing guidance, not a completion or evergreen-currency promise.

Sources and verification boundary

Primary source passages were read and captured on 2026-10-08, with raw/canonical hashes, exact quote offsets and claim-specific entailment retained in the batch evidence. Historical pins describe their own versions, not current deployment recommendations.

Exact probability, mapping, range and in-process Flask response tests were executed. SQLite memory is not durable production storage; no Redis/PostgreSQL/CDN/analytics service, reputation API or load benchmark was run.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.