Designing a URL Shortener
A URL shortener looks trivial — generate a code, store a mapping, redirect — until the traffic is read-heavy and spiky enough that cache correctness, write durability, and analytics isolation all become real engineering problems. This walkthrough goes from a blank whiteboard to the distributed system underneath that simple-looking redirect.
Problem Definition
A URL shortener accepts a long URL and returns a short one. Anyone who visits the short URL is redirected to the original. The system must also track click analytics and handle viral traffic spikes.
The core loop looks trivial: store a mapping, look it up on redirect. But at the scale assumed for this design — a read-heavy workload, Zipf-distributed traffic, viral spikes, and a design assumption of roughly 100 reads per write — many naive decisions become bottlenecks that a smaller deployment would never hit. These figures are illustrative design targets for this walkthrough, not universal properties of every URL shortener.
This is the same underlying problem interviewers mean by "design TinyURL" or "design Bitly" — both are URL shortening services, and the generate-a-code / store-a-mapping / redirect core described below applies directly regardless of which brand name shows up in the prompt.
Why this problem is interesting
URL shorteners are deceptively educational. They raise four design concerns worth reasoning through explicitly, rather than assuming a predetermined solution for each: (1) short-code generation and namespace management — how do you produce short, unique codes at scale, and how enumerable should they be? (2) very fast redirect reads — how do you serve a high, spiky read rate with low latency? (3) durable mapping writes — how is the authoritative mapping stored and kept consistent with caches? (4) analytics isolation — how do you record every click without coupling redirect availability to the analytics pipeline?
Scope
In scope: URL shortening (create), redirect (read), expiry (TTL), click analytics (async), custom aliases. Out of scope: user authentication/accounts (unless needed to explain abuse quotas or ownership), link previews/OG metadata, QR code generation, bulk imports, team/enterprise features, billing, and a full malware-detection platform — each is a solvable extension but not the core design problem.
Visual Design Journey
Watch a single overloaded database evolve, bottleneck by bottleneck, into a cache-first, horizontally scaled redirect service with safe distributed ID generation and a fully asynchronous analytics pipeline.
Requirements
Functional requirements describe what the system does. Non-functional requirements describe the properties that shape every architecture decision — and several of the figures below are illustrative targets for this design, not universal facts.
Functional
Create short URL: Accept a long URL (up to 2048 chars), return a short code (length chosen from the expected mapping volume and occupancy — see Architecture Deep Dive → Short-Code Generation). Optionally accept a custom alias. Whether the same long URL always returns the same short code is a product decision, not an automatic optimization — see Trade-offs → Deterministic Deduplication vs Multiple Codes per URL. Redirect: GET /{code} → a redirect status to the original URL. Expiry: Accept an optional TTL on creation; an expired mapping must stop redirecting. Analytics: Track redirect activity for reporting (total clicks, clicks over time, top referrers, device/geo breakdowns where the product requires them).
Redirect semantics
The redirect status (301, 302, or 307) is a product/cacheability decision, not a fixed rule. It should not be reduced to '301 caches, 302 doesn't' — browser/intermediary caching behavior depends on the status code together with explicit HTTP caching headers and client/proxy behavior, not the status code alone. A design that needs repeat-click analytics visibility or supports destinations that can change should lean toward a temporary-redirect posture; a design with genuinely immutable, high-traffic destinations can lean toward a permanent-redirect posture to reduce repeated service traffic. See Trade-offs → Permanent vs Temporary Redirect and Architecture Deep Dive → Redirect Semantics and HTTP Caching.
Expiry semantics
Three distinct concepts, easy to conflate: logical expiry — the redirect must stop working once expires_at passes, regardless of whether the row still physically exists; physical deletion — old rows can be removed asynchronously, later, as storage hygiene; cache expiry — any cached copy of the mapping must never outlive the mapping's own authoritative expiry. This distinction matters throughout Architecture Deep Dive and Failure & Scale Scenarios.
Non-Functional
Redirect latency
A low-latency target (illustrative, e.g. p99 under tens of milliseconds) reflects that users expect near-instant navigation after clicking a link. A cache hit is typically far faster than a cache miss.
Availability
High availability for the redirect path is important — a down URL shortener breaks every link it ever created, for every service that used it. The write path (URL creation) can often tolerate more downtime than the read path.
Read/write ratio
This design assumes a read-heavy ratio (illustratively, on the order of 100 reads per write) as a workload assumption for this walkthrough — not an intrinsic property of all URL shorteners. Viral events can produce much larger read spikes than the steady-state baseline.
Durability
URL mappings must not be lost. Once a short URL is created, it must redirect correctly until its TTL expires. Loss of a mapping permanently breaks every published link to it.
Analytics and privacy
Whether to retain raw IP address, User-Agent, or Referer values is a privacy/security requirement, not a default architectural given. These fields may need to be transformed, truncated, hashed, aggregated, or omitted entirely depending on the product's privacy requirements.
Capacity Estimation
Capacity estimation for this workload is dominated by read throughput, not storage. All figures below are illustrative for the assumed workload — treat them as a demonstration of the estimation process, not universal benchmarks.
Storage
Assume 100 million URL mappings. Each record: roughly 8 bytes (short code) + ~200 bytes (average long URL) + a handful of timestamp/metadata fields ≈ 230-300 bytes; round to ~500 bytes with indexes and overhead. 100M × 500B ≈ 50 GB — this fits comfortably in RAM on a well-provisioned database server. Under this assumed 100M-mapping workload, mapping storage is modest relative to redirect throughput and does not drive the initial architecture. That does not mean storage is irrelevant at every scale — indexes, replicas, backups, retention, and long-lived growth still matter operationally as the dataset grows.
Read throughput
100M redirects/day ÷ 86,400 seconds ≈ 1,157 redirects/sec baseline (illustrative). Assuming a peak of roughly 100x baseline gives a design target on the order of 100,000 redirects/sec for this walkthrough. A single relational database instance can handle a meaningful number of simple primary-key lookups per second, but the exact ceiling depends heavily on hardware, connection pooling, index/buffer-cache state, and concurrency — treat any specific lookups/sec figure as illustrative, not a benchmark to design against. At the assumed 100:1 read:write ratio, the same baseline implies only ≈12 URL creations/sec (≈1,157/sec if writes also peak at 100x) — illustrative. A distributed in-memory cache can handle a very high GET rate per node, which is why this design leans on caching rather than scaling the database directly for reads.
Cache sizing
Assuming URL access follows a Zipf-like distribution, a small fraction of URLs (illustratively, the top 1%) can account for the large majority of traffic. If that top slice is roughly 1M URLs at ~300 bytes average, that's about 300 MB — small enough that a modestly-sized cache node can hold it many times over. The cache ROI is large under this assumption, though the actual skew depends on the specific traffic pattern.
Cache-hit demonstration
Assuming a 92% cache hit ratio (an illustrative assumption for this design, not a guaranteed outcome), database read load drops from 100k reads/sec to roughly 8k reads/sec — a meaningful reduction. This demonstrates why caching is valuable at this workload; the actual achieved hit ratio for any real deployment depends on traffic skew, TTL policy, and cache size, and should be measured rather than assumed.
- ~50 GB for 100M URLs under this assumed workload — modest relative to read throughput
- ~100k redirects/sec design target for this walkthrough — an illustrative peak assumption
- A small fraction of URLs can dominate traffic under a Zipf-like distribution — cache sizing follows from that assumption
- Write throughput at this workload's assumed creation rate is comfortably handled by a modern database
- A high assumed read/write ratio drives the caching-first design — but it is a workload assumption, not a universal fact
Naive Design & Why It Fails
The single-server, single-database design is the correct starting point. It is not wrong — it's appropriate for an early-stage deployment. Understanding when and why it breaks is more valuable than skipping straight to the distributed design.
The naive design
Client → App Server → relational database. One table: url_mappings(short_code, long_url, created_at, expires_at). Index on short_code for fast lookups. Insert on write, SELECT on redirect. Simple, correct, ships quickly.
When it works
For a comparatively modest redirect rate, a single well-provisioned application server and database can handle traffic comfortably, especially with connection pooling. In this illustrative workload, assume the single application/database path begins missing its latency or capacity targets as redirect traffic grows well beyond that — the exact threshold depends on hardware and query patterns, not a fixed number. For an early-stage product, this is exactly the right amount of infrastructure — don't build what isn't needed yet.
When it breaks
Zipf distribution hits
A popular URL generates a large volume of essentially identical queries. The same row gets read over and over — connection pools come under pressure, and latency climbs.
Wasted repeated work
Re-fetching the same, unchanging URL mapping from the database thousands of times per second — getting the same result every time — is pure wasted I/O. The data never changes between requests, which is exactly what caching exists to exploit.
Single point of failure
One database means one failure domain. Any maintenance, failover, or crash takes the redirect path down with it.
ID Generation
The short code is the primary key of this system. Generating it correctly means balancing uniqueness, length, and — depending on requirements — non-enumerability. There are four main approaches, each a genuine alternative with real trade-offs; none is a predetermined 'correct' answer independent of requirements.
Approach A: Auto-Increment + Base62
Use the database's auto-increment integer ID, then encode it in Base62. Simple: no random-collision risk, deterministic, short codes at first, growing gradually as the counter increases. The trade-off: sequential IDs are trivially enumerable — anyone can iterate through codes and harvest every URL ever shortened, which is a privacy/security consideration for some products.
Approach B: Hash Truncation
Hash the long URL and take a truncated prefix, encoded in Base62. Non-sequential and deterministic (the same URL always produces the same code, if that is a desired product property). Trade-offs: truncated-hash collisions are meaningful and require conflict detection and retry; and determinism may be unwanted if the same URL submitted by different owners/campaigns should get independent codes.
Approach C: Random Base62
Generate a random value and encode it in Base62 (alphabet [0-9a-zA-Z], 62 symbols). Non-sequential and non-enumerable. Code length should be chosen from the expected mapping volume, desired occupancy, acceptable retry rate, alias-namespace reservation, and future growth — not defaulted to a single 'correct' length. A small but non-zero collision probability is handled with a database UNIQUE constraint and a bounded retry loop; see Architecture Deep Dive → Collision Handling for the precise math distinguishing per-attempt collision probability from the birthday-bound probability that a collision has ever occurred.
Approach D: Snowflake-style Distributed IDs
A lightweight ID-generation scheme produces integers from a timestamp component, a worker/node-ID component, and a per-millisecond sequence counter, then encodes the result in Base62. No coordination is needed between nodes at request time, and IDs are roughly time-ordered (useful for debugging, though this ordering is also a mild predictability trade-off — see Architecture Deep Dive → Distributed ID Generation Mechanics).
Why consider Snowflake — and why it is not mandatory
It's worth understanding what problem Snowflake-style generation actually solves, and what it does not. A central database sequence is often assumed to become a serialization bottleneck once a system has multiple write-path servers — but database sequences are specifically optimized for concurrent ID allocation, and at the write throughput assumed in this design (illustratively on the order of 1,000 URL creations/sec at peak, derived in Capacity Estimation above), a central sequence can be entirely reasonable. Reasons to move away from a central sequence are more specific: removing a dependency on the database for ID allocation itself, a genuine multi-region/local-generation requirement, extremely high creation throughput beyond what a sequence comfortably supports, an operational preference for independently generated IDs, or a desire to avoid directly enumerable identifiers (which can also be addressed separately through obfuscation, without switching ID schemes at all).
A related idea: range allocation
Pre-allocating blocks of IDs (Node A claims 1-1000, Node B claims 1001-2000, and so on) reduces the frequency of central-allocation calls. If a node crashes partway through its block, the unused IDs in that block are simply skipped — this is a harmless gap, not an unrecoverable operational problem; identifiers do not need to be dense. The real trade-offs are: block ownership/recovery bookkeeping adds implementation complexity, and the sequential/enumerable character of the underlying counter remains unless the encoded value is further obfuscated.
Snowflake-style generation becomes attractive when a system genuinely wants decentralized unique-ID generation with high throughput and no per-request database round-trip — for example, many independent write nodes generating IDs without any shared coordination point. It introduces its own operational surface: worker-ID allocation across nodes, handling of clock rollback (which can otherwise produce duplicate or out-of-order IDs), and Base62-encoding a Snowflake ID does not make it non-enumerable — the encoded value still carries structured, time-ordered information, so an additional obfuscation/permutation step (or simply choosing random IDs) is needed if unpredictability is a real requirement.
Caching Strategy
Caching is not an optimization bolted onto this design — it is the architecture. A high read/write ratio combined with a skewed (Zipf-like) access pattern makes aggressive read caching the single highest-leverage design decision.
Cache-aside with eager population on write
The standard approach: (1) the app checks the cache for the short code; (2) on a hit, return the long URL immediately; (3) on a miss, query the authoritative database, populate the cache with an appropriately-bounded TTL, and return the long URL. On URL creation, the app can also eagerly populate the cache right after the authoritative database write completes, so that the first redirect after creation is already a cache hit. This is eager cache population after the durable write — the cache does not itself own the write, the database does.
TTL correctness
A cached mapping must never outlive the underlying mapping's own expiry. Rather than assigning every mapping a fixed cache TTL regardless of its expires_at, bound it explicitly: cache_ttl = min(configured_cache_ttl, expires_at - now). For non-expiring mappings, a normal cache TTL applies. On explicit update or deletion, invalidate the cache entry directly rather than waiting for TTL expiry.
Negative caching
Caching a short-lived 'not found' result for nonexistent codes protects the database from repeated lookups of codes that will never resolve — useful against both random guessing and scanning traffic. The trade-off: a negative cache entry must not cause a just-created mapping or newly-claimed custom alias to appear falsely missing, so creation should invalidate any matching negative entry, or the negative TTL should be kept short.
Cache stampede protection
A popular mapping is usually safe once cached — but a cache restart, mass eviction, or many simultaneous expirations can make a large volume of requests miss the same code at once, which can turn a normal cache miss into a database thundering herd. Request coalescing (ensuring only one in-flight database lookup exists per code) and jittered TTLs are the standard defenses; see Architecture Deep Dive → How Do URL Shorteners Prevent Cache Stampedes?.
Two-tier caching for hot keys
At high concentrated traffic, a single cache key for one very popular URL can become a bottleneck on the single node/shard that owns it — clustering spreads different keys across nodes, but does not spread one hot key's traffic. An optional process-local cache in each app server, holding a modest number of the hottest mappings, removes this class of request from the shared cache entirely. See Architecture Deep Dive → Hot-Key Protection.
Expected hit ratio
With an appropriately bounded TTL and eager population on write, hit ratio should stabilize at a high level for genuinely skewed traffic — but the achieved ratio depends on the real traffic pattern and cache sizing, and should be measured rather than assumed.
Read & Write Paths
Tracing the cache-hit path, the cache-miss path, and the write path separately shows the full request lifecycle and exactly where each correctness check belongs.
Read path — cache hit
1. Client requests the short code via the load balancer, which routes to an app server. 2. The app server validates/normalizes the short code format. 3. It checks a process-local cache if one is used. 4. It checks the shared cache. 5. On a hit, it verifies the cached mapping has not logically expired if expiry metadata is cached alongside it. 6. It emits an analytics event best-effort/asynchronously. 7. It returns the redirect. This path is typically the fastest — illustratively low-single-digit milliseconds — with no database involved.
Read path — cache miss
Steps 1-3 are the same. On a shared-cache miss: the app queries the authoritative store or a read replica, distinguishing a genuinely nonexistent code from an expired one (they should produce different responses — not found vs. expired). If found and valid, it populates the cache with a TTL bounded by the mapping's remaining lifetime, emits the analytics event, and returns the redirect. This path is slower than a cache hit since it involves a database round-trip — illustratively single-digit-to-tens of milliseconds, depending on infrastructure.
Write path — shorten a URL
1. Validate basic URL syntax/scheme — this does not require the service to fetch the destination; if reputation/preview checks that do fetch the destination are added later, treat that as a separate, security-sensitive subsystem (SSRF-aware) rather than a default part of validation. 2. If deterministic deduplication is a product requirement, check for an existing mapping using whatever fields actually define identity for this product (see Trade-offs → Deterministic Deduplication vs Multiple Codes per URL) — this is a product decision, not an automatic optimization every design must include. 3. Generate a code (see ID Generation above). 4. Insert into the authoritative store, retrying on a UNIQUE conflict within a bounded retry budget. 5. Eagerly populate the cache. 6. Return the short URL. This path is dominated by the database write acknowledgment; treat any specific latency figure as illustrative.
Analytics Architecture
Every redirect can be tracked for analytics — but doing this synchronously on the redirect path couples redirect health to the analytics pipeline's health, which this design deliberately avoids.
Why synchronous analytics is risky
At a high assumed redirect rate, a synchronous analytics write on every redirect means the database (or analytics store) must absorb write volume proportional to all redirect traffic, not just creation traffic — and it directly adds to redirect latency. Coupling redirect UX to analytics-store health is a design smell for this workload.
Async, but not free
The redirect path publishes a small event to a message queue/event stream and returns without waiting on it. This is not automatically free: a slow or unavailable broker must not be allowed to degrade redirect latency or availability. The producer needs an explicit, bounded policy — a publish timeout, a non-blocking/async producer where the client supports it, and an explicit choice of what happens on failure (drop, sample, or buffer within strict bounds) — redirect serving must never wait indefinitely on analytics. See Architecture Deep Dive → Analytics Delivery Mechanics and Failure & Scale → Analytics Broker Unavailable.
Event schema and privacy
A compact event typically includes the short code, a timestamp, and whatever fields the analytics product actually needs. Raw IP address, User-Agent, and Referer should not be treated as a default retention requirement — these fields depend on the product's privacy/security requirements and may be transformed, truncated, hashed, aggregated, or omitted.
Partitioning trade-off
Partitioning events by short code gives per-URL ordering, but concentrates a viral URL's entire event volume onto a single partition. If analytics primarily needs commutative aggregates (counts, counts over time), spreading events across partitions and merging during aggregation avoids that hotspot at the cost of strict per-URL ordering. See Architecture Deep Dive → Analytics Partitioning for the full comparison.
Analytics store and deduplication
An append-optimized analytics store (for example, a column-oriented store suited to batch inserts and fast aggregation) is a good fit for this workload — but it is one suitable example, not the only correct choice. At-least-once delivery can produce duplicate events; if exact click counts matter, include a stable event ID and define explicit deduplication semantics rather than assuming a storage engine's built-in merge behavior handles this automatically. If approximate analytics is acceptable, exact deduplication can be intentionally skipped for cost/throughput reasons — state which choice the design makes.
Data Model
The data model is intentionally minimal: a mapping table as the authoritative source of truth, and a separate analytics store with a completely different write/query pattern.
url_mappings
CREATE TABLE url_mappings (
short_code VARCHAR(50) PRIMARY KEY,
long_url TEXT NOT NULL,
dedup_key BYTEA, -- optional: only if deterministic dedup is a product requirement
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
owner_id BIGINT, -- optional: only if accounts/ownership are in scope
status TEXT DEFAULT 'ACTIVE' -- optional: only if disable/abuse suspension is supported
);
CREATE INDEX idx_dedup_key ON url_mappings (dedup_key);The column width above is sized to accommodate both generated codes and custom aliases under the same primary key and uniqueness constraint. If generated codes and custom aliases have very different length requirements, size the schema to the longer of the two, or track separate length constraints in application-level validation rather than defining the schema only around the generated-code length. owner_id is optional — do not make it mandatory if user accounts are explicitly out of scope for the product. status is optional and only needed if the product supports disabling/suspending a mapping without deleting it (for example, after an abuse report).
Cache schema
Key: the short code. Value: the long URL (and, where cache-side expiry checks are used, the mapping's expires_at). TTL: bounded by min(configured_ttl, expires_at - now) — never a blind fixed TTL applied regardless of the mapping's own expiry. On explicit update/deletion, invalidate the cache entry directly.
Analytics events
CREATE TABLE click_events (
short_code String,
clicked_at DateTime,
event_id String, -- for exact deduplication, if required
ip_hash String, -- optional, hashed/truncated per privacy requirements
country String, -- optional, derived
device_type String, -- optional, derived
referer_host String -- optional
) ENGINE = MergeTree()
ORDER BY (short_code, clicked_at);Final Architecture
The final architecture earns every component by solving a specific, demonstrated problem — nothing is added speculatively, and no specific deployment topology below is a fixed requirement.
Component ownership
Load Balancer / routing layer
Distributes incoming traffic across app instances; health checks remove unhealthy instances. App servers are stateless, so sticky routing is not required.
URL Service (stateless)
Owns validation, code generation, redirect handling, expiry enforcement, cache orchestration (local + distributed), and bounded/best-effort analytics emission. This is where every correctness check from the read/write paths above actually lives.
Cache (local + distributed)
Derived, disposable acceleration for mapping lookups — never the durable source of truth. Redirect correctness must survive complete cache loss, at higher latency and higher database load.
Mapping database (primary + replicas as needed)
The authoritative store for mappings and uniqueness state. Replication topology (synchronous, semi-synchronous, or asynchronous) should be an explicit, understood choice — synchronous replication should not be assumed unless stated, since asynchronous replication has real implications for cache-miss read freshness (see Architecture Deep Dive -> Database Access & Indexing).
Analytics event transport and workers
A separate availability domain from redirect serving. Workers consume, batch, and write to the analytics store; a slow or unavailable transport must never propagate back into redirect latency or availability.
Analytics store
An append-optimized store suited to batch writes and fast aggregation, completely isolated from the mapping database's query load.
Instance counts are illustrative
Any specific instance count (a given number of app servers, cache nodes, database replicas) shown in a diagram is an illustrative deployment quantity for a particular scale, not 'the' production architecture. The right count depends on measured load and redundancy requirements.
Production Evolution
A URL shortener doesn't need to be built at its most complex stage from day one. What matters is recognizing the signal that a stage transition is actually needed — not a fixed traffic number memorized in advance.
Stage 1 — Single server + database
One app server, one database, connection pooling, an index on the short-code column. Handles a comparatively modest, steady request rate with zero additional infrastructure. Move to Stage 2 when measured signals — redirect p99 missing its target, or database read load becoming the dominant resource cost — indicate the single-database path is the bottleneck, not at a pre-memorized fixed RPS number.
Stage 2 — Add a distributed cache
Introduce cache-aside with an expiry-bounded TTL and eager population on write. This is typically the highest-leverage single change available, since it directly targets the repeated-read problem identified in the naive design. Move to Stage 3 when application-tier CPU/connection saturation, or analytics writes competing with the mapping database, become the limiting factor.
Stage 3 — Load balancer, replicas, and async analytics
Load balancer + multiple app servers + distributed cache + database primary with read replica(s) + an analytics event transport and workers. This configuration handles the large majority of realistic URL-shortener deployments. Move to Stage 4 when per-instance saturation persists even after horizontal scale-out, or when regional latency/SLO requirements demand a multi-region deployment.
Stage 4 — Multi-region
Multi-region deployment, geo-aware routing, a cache topology sized for multiple regions, and a database/ID-generation strategy that supports the chosen write topology. This stage introduces real distributed-systems questions that should not be glossed over: where are new codes generated if multiple regions can create mappings, how is global uniqueness guaranteed, what happens to a redirect served in a different region immediately after creation (before replication/propagation completes), and how are caches invalidated across regions. Only adopt genuine multi-region write support if the product actually requires it — a single-region write path with multi-region read caching/edge delivery is a simpler, often sufficient alternative.
CDN/edge caching of redirects — treat carefully
Caching redirect responses at the edge can be valuable for immutable, high-traffic, publicly cacheable links — but it changes analytics visibility (edge-served requests may never reach the origin) and invalidation semantics (an edge-cached redirect for an updated or expired mapping needs an explicit invalidation path). It is a deliberate trade-off for specific hot content, not a default requirement at any particular stage.
Summary & Interview Notes
A URL shortener is a read-dominated, cache-first system with genuinely interesting problems in ID generation, cache correctness, and analytics isolation. The design itself is not hard — the reasoning is what interviewers want to see.
Things to remember
- Caching is the architecture under a read-heavy assumption — but state the read/write ratio and hit-ratio numbers as workload assumptions, not universal facts
- Random Base62 with a UNIQUE constraint and bounded retry is a reasonable simple choice; Snowflake-style generation is an alternative for decentralized high-throughput needs, not a mandatory upgrade once multiple write servers exist
- Redirect status (301/302/307) is a product decision governed by status plus caching headers plus client behavior — never reduce it to '301 caches, 302 doesn't'
- Cache TTL must be bounded by the mapping's own expiry — an unbounded fixed TTL can keep an expired URL redirecting from cache
- Async analytics still needs an explicit, bounded producer policy — 'async' does not automatically mean 'safe from broker failures'
- Failure order under a viral spike depends on the actual request path and current deployment state — reason through it, don't recite a memorized order