System design / HLD + LLDIntermediate to advanced · 9 sections · 7 figures · 14 min read

URL Shortener

  • Caching
  • Key generation
  • Read scaling
  • Designed · build in progress
The constraint

Redirects outnumber creations 300 to 1 and sit on every click path, so reads must answer in milliseconds.

The decision

Pre-allocated ID blocks, permuted and Base62-encoded, served through cache-aside Redis with negative caching.

11.6k/s
peak redirects
7 chars
for 3.5 trillion codes
20 ms
p99 target, cache hit

1. Problem and requirements

Users submit a long URL and receive a short link such as https://mkm.li/aZ3x9Qp. Anyone opening the short link is redirected to the original destination.

Functional requirements

# Requirement
F1 Create a short link for a URL, optionally with a custom alias.
F2 Redirect a short code to its destination.
F3 Links can expire at a set time, and their owner can disable or delete them.
F4 Custom aliases are unique; a taken alias is refused, never overwritten.
F5 Count clicks per link, with daily totals, referrers and countries.
F6 Refuse known malicious destinations and stop abuse of link creation.

Non-functional requirements

# Requirement Target
N1 Redirect latency at the service p99 under 20 ms for cache hits, under 60 ms for misses
N2 Redirect availability 99.99%
N3 Creation availability 99.9% (it can be briefly down without breaking existing links)
N4 A deleted or disabled link stops redirecting Within 60 seconds everywhere
N5 Analytics Eventually consistent; minutes of delay are acceptable
N6 Codes are short 7 characters for the first 3.5 trillion links

Out of scope

Link editing (changing the destination of an existing code), QR code generation and team workspaces. Destination editing is deliberately excluded because it interacts badly with permanent (301) redirects cached by browsers.

301 or 302?

301 Moved Permanently 302 Found
Browser caching Cached, often indefinitely Not cached by default
Analytics Repeat clicks never reach us Every click is counted
Disable or expire Browsers that cached it keep redirecting Takes effect immediately
Server load Lower Higher

Decision: 302 with Cache-Control: private, max-age=0 by default, because F3, F5 and N4 all require every click to reach us. Owners can opt a link into 301 when they want maximum speed and accept losing analytics and fast disabling for it.

2. Capacity estimation

Assumptions: 10 million new links a month, 100 million redirects a day, peak traffic 10× the average, an average stored mapping of 500 bytes, and links kept for 5 years.

Metric Working Result
Creation, average 10,000,000 / (30 × 86,400 s) ≈ 3.9 writes/s
Redirects, average 100,000,000 / 86,400 s ≈ 1,157 reads/s
Redirects, peak (10×) 1,157 × 10 ≈ 11,574 reads/s
Read:write ratio 1,157 / 3.9 ≈ 300 : 1
Mapping storage per month 10 M × 500 B ≈ 5 GB
Mapping storage per year 5 GB × 12 ≈ 60 GB
Mapping storage over 5 years 60 GB × 5 ≈ 300 GB raw, roughly 600 GB with indexes and overhead
Code space needed 10 M × 12 × 5 600 million codes, far inside 62⁷ ≈ 3.5 × 10¹²
Click events 100 M/day × about 200 B ≈ 20 GB/day before aggregation
Hot set for the cache Links clicked in the last day; assume 20 M × about 300 B ≈ 6 GB of Redis

These are estimates from stated assumptions, not measurements. Real capacity also depends on burstiness, cache hit rate and replication.

What this says: writes are trivial. The work is the read path at about 12,000 lookups a second at peak, plus a click-event stream of 100 million a day that must not slow it down. One PostgreSQL primary with a replica can serve the misses, provided the cache absorbs most reads.

3. Architecture and APIs

High-level architecture

FIG 01ArchitectureHigh-level architecture
Component Responsibility
Redirect service GET /{code}: cache lookup, database on miss, expiry and disabled checks, 302. Publishes a click event without waiting.
Link API Create, read, disable and delete links; custom aliases; abuse checks.
Redis Cache-aside for code → destination, status, expiry; negative entries for unknown codes; rate-limit counters.
PostgreSQL Source of truth for links. Primary for writes, replica for redirect misses.
Kafka clicks Buffers click events so analytics never sit on the redirect path.
Analytics aggregator Rolls clicks into per-link daily counts, referrers and countries.

Redirect and creation are separate deployments: they have different traffic (1,000× apart), different SLOs and different failure tolerance. A spike in creations, or a bad deploy of the Link API, must not affect redirects.

API contracts

Method and path Purpose Success Notable errors
POST /api/links Create a link 201 400 INVALID_URL, 409 ALIAS_TAKEN, 422 UNSAFE_DESTINATION, 429
GET /api/links/{code} Link details and status 200 404
PATCH /api/links/{code} Disable or enable, change expiry 200 403, 404
DELETE /api/links/{code} Delete (tombstone) 204 403, 404
GET /api/links/{code}/stats?from&to Click analytics 200 403
GET /{code} Redirect 302 404 NOT_FOUND, 410 GONE (expired, disabled or deleted)
POST /api/links
Authorization: Bearer <token>
Idempotency-Key: 1b9e…

{ "url": "https://example.com/very/long/path?utm=x", "alias": "launch-2026", "expiresAt": "2027-01-01T00:00:00Z" }
201 Created
{ "code": "launch-2026", "shortUrl": "https://mkm.li/launch-2026",
  "url": "https://example.com/very/long/path?utm=x", "expiresAt": "2027-01-01T00:00:00Z", "status": "ACTIVE" }

410 Gone (not 404) for expired or disabled links tells clients the code existed and will not come back, so it is never reused for a different destination.

Deployment

FIG 02ArchitectureDeployment

4. Internal design and workflows

Class diagram

FIG 03Class modelClass diagram

Identifier generation: the decision worth explaining

Approach Benefits Limitations
Database sequence + Base62 Simple, compact, collision-free Sequential, so codes are guessable; one round trip per create
Random Base62 token Unpredictable Needs a uniqueness check and retry; collisions grow with fill rate
Hash of the URL (e.g. first 7 chars of SHA-256 in Base62) Same URL gives the same code Collisions must be handled; different owners and expiries cannot share a code
Distributed generator (Snowflake-style) + Base62 No central allocator 64-bit IDs encode to 11 characters; clock-skew handling
Pre-allocated ID blocks + scrambled Base62 (chosen) Collision-free, no per-create round trip, codes not sequential A crashed instance wastes the rest of its block (harmless)

How the chosen approach works:

  1. Each Link API instance reserves a block of 10,000 IDs at a time with one atomic statement: UPDATE id_block SET next = next + 10000 RETURNING next - 10000.
  2. It hands out IDs from the block in memory. No database round trip per link, no collisions across instances.
  3. Before encoding, the ID goes through a reversible bit permutation (a small Feistel network over the ID space with a secret key). Consecutive IDs become unrelated-looking codes, so aZ3x9Qp reveals nothing about aZ3x9Qq.
  4. The result is Base62-encoded and left-padded to 7 characters.

Encoding is not encryption. The permutation makes codes non-sequential, which stops casual enumeration. Links that must stay private should not rely on an unguessable URL at all: they need authentication.

static final char[] ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();

String encode(long id) {                 // id already permuted, 0 <= id < 62^7
    char[] out = new char[7];
    for (int i = 6; i >= 0; i--) { out[i] = ALPHABET[(int) (id % 62)]; id /= 62; }
    return new String(out);
}

Custom aliases go through the same table. The primary key on code makes a taken alias fail with a constraint violation, which becomes 409 ALIAS_TAKEN. Aliases are restricted to [A-Za-z0-9-]{4,32}, and a reserved-word list blocks api, admin, login and lookalikes.

Data model and indexes

FIG 04Data modelData model and indexes
CREATE TABLE link (
  code        text PRIMARY KEY,                         -- unique code and alias space
  destination text NOT NULL CHECK (length(destination) <= 2048),
  owner_id    uuid NOT NULL,
  created_at  timestamptz NOT NULL DEFAULT now(),
  expires_at  timestamptz,
  status      text NOT NULL DEFAULT 'ACTIVE',           -- ACTIVE | DISABLED | DELETED
  permanent   boolean NOT NULL DEFAULT false
);
CREATE INDEX link_by_owner  ON link (owner_id, created_at DESC);            -- "my links"
CREATE INDEX link_expiring  ON link (expires_at) WHERE status = 'ACTIVE';   -- expiry sweeper

Deleted links keep a tombstone row (status = 'DELETED', destination cleared) so a code is never reissued to someone else.

Redirect sequence (cache-aside with negative caching)

FIG 05SequenceRedirect sequence (cache-aside with negative caching)

Create sequence

FIG 06SequenceCreate sequence

The DEL after insert matters: if someone requested the alias before it existed, a MISSING marker is cached for 60 seconds, and deleting it makes the new link work immediately.

FIG 07State machineLink states

EXPIRED is computed at read time from expires_at rather than written by a job, so an expiry is exact even if the sweeper runs late. The sweeper only evicts cache entries and archives rows.

Data ownership and transaction boundaries

  • The link table is owned by the Link API. The redirect service only reads it.
  • Creation is a single-row insert. No multi-step transaction is needed.
  • Click counts are owned by the aggregator and are never written on the redirect path.

5. Failure modes and consistency

Scenario What would go wrong Design response
Redis is down Every redirect hits the database Circuit breaker on the cache client; reads fall back to the replica with a per-instance in-memory LRU of the hottest 50,000 codes (TTL 30 s). Load is shed with 503 before the replica is overwhelmed.
Replica is behind the primary A just-created link 404s on another server A replica miss is confirmed against the primary before answering 404 and caching MISSING. Genuine misses are rare after negative caching, so this costs the primary little.
A link goes viral (one hot key) One Redis shard saturated In-process LRU in front of Redis for the top codes; with the 302 default, the CDN can still cache for a few seconds if the owner allows it.
Cache stampede when a hot entry expires Thousands of identical database reads Single-flight per code inside each instance (one loader, others wait on its future), plus TTL jitter so entries do not expire together.
Scanners request random codes Database flooded with misses Negative caching (MISSING for 60 s), rate limits per IP, and a Bloom filter of existing codes checked before the database.
Link deleted or disabled Old destination keeps working PATCH and DELETE evict the cache entry; N4 is met by the 1-hour TTL plus eviction on write. 301 links can stay cached in browsers, which is why they are opt-in.
Kafka unavailable Redirects slow down or fail Click publishing is asynchronous with a bounded in-memory buffer; when full, events are dropped and counted (clicks_dropped_total). Analytics lose accuracy, redirects never wait.
Duplicate click events Inflated counts Each event carries an ID; the aggregator de-duplicates within a window. Click counts are documented as approximate (bots, prefetches and retries make exact counts meaningless).
Malicious destination added after creation We redirect to phishing Daily re-scan of active destinations; flagged links switch to an interstitial warning page instead of redirecting.
Redirect loop (short link to another short link) Infinite redirects Creation rejects destinations on our own domain and known shortener domains.
Create retried after a timeout Two links for one request Idempotency-Key stored with the response for 24 h.

Caching and invalidation, summarised

  • Pattern: cache-aside. The redirect service reads Redis, falls back to the database and populates the cache.
  • TTL: one hour, or the time until expiry if sooner, with ±10% jitter.
  • Negative entries: 60 seconds, deleted on create.
  • Invalidation: explicit DEL on disable, delete and expiry change. The TTL is the safety net if an eviction is lost.
  • Consistency: a disabled link can redirect for at most the time between the write and the eviction (normally milliseconds), or one TTL if the eviction fails.

Security and abuse

  • Creation needs an account or an API key; anonymous creation (if offered) is limited per IP and requires a challenge.
  • Destinations are checked against Google Safe Browsing and an internal blocklist at creation and re-checked daily.
  • Only http and https destinations are allowed; javascript:, data: and file: are rejected.
  • Rate limits: creation per account and per IP (see 03 · Rate Limiter), redirect misses per IP.
  • Owners can only manage their own links; GET /api/links/{code}/stats is owner-only.

6. Scaling evolution

Stage Trigger Design Why
1. MVP Launch One Spring Boot app, PostgreSQL, sequence-based IDs, 302 Fewest moving parts
2. Read-heavy Redirect p99 over target or DB CPU over 50% Split redirect and creation; Redis cache-aside; read replica; stateless replicas Reads dominate 300 to 1; isolate their path
3. Hot links One code over 1,000 req/s In-process LRU, single-flight loading, TTL jitter, optional edge caching Remove hot-key and stampede risk
4. Analytics volume Click writes compete with reads Kafka click stream, separate aggregator and analytics store (ClickHouse or partitioned PostgreSQL) Keep analytics off the latency-critical path
5. Very large mapping store Table over 1 TB or write IOPS limits Hash-partition link by code (codes are uniform after permutation, so partitions are even), more replicas, regional read replicas The workload is key-value; it partitions trivially
6. Multi-region Global users, latency SLO by region Regional redirect stacks with regional replicas and caches; creation stays single-writer; ID blocks are region-tagged Reads go local; writes are rare enough to stay central

7. Operations and validation

Metrics and SLOs

Signal SLO or alert
Redirect latency p99 under 20 ms (hit) and 60 ms (miss); alert on 5-minute burn rate
Redirect error rate (5xx) Under 0.01%; page on breach
Cache hit ratio Expect over 95%; alert under 85%
Database reads per second from redirects Alert above 2,000 (cache not doing its job)
Click events dropped Alert above 0.1%
Kafka consumer lag (aggregator) Alert above 10 minutes of events

Test plan (to build)

Test Proves
Base62 round trip for 0, 1, 61, 62, 62⁷ − 1 and random values Encoding is correct and fixed-width
Permutation is a bijection over a sampled range No two IDs share a code
Two concurrent creates with the same alias One 201, one 409
Disable, then redirect within one second 410, cache evicted
Miss storm: 1,000 concurrent requests for one cold code One database read (single-flight)
Redis stopped mid-test Redirects keep working within SLO at reduced capacity

Benchmark (to measure)

A k6 test drives redirects with a Zipf-distributed code set (a few codes very hot, a long tail cold) at 1×, 5× and 10× the average rate. To be recorded: p50, p95 and p99 for hits and misses, the cache hit ratio, and database CPU. A second run with Redis stopped records the degraded mode. No results are claimed until these tests have been run.

8. Decision records

DR-1: Pre-allocated ID blocks with a permutation

  • Decision: reserve ID blocks of 10,000 per instance, permute, Base62-encode to 7 characters.
  • Rejected: random tokens (a uniqueness check on every insert, rising collision rate); URL hashing (cannot give two owners different expiries for the same URL); Snowflake IDs (11-character codes for a capability we do not need yet).
  • Consequence: no collisions, no round trip per create, short and non-sequential codes.

DR-2: 302 by default

  • Decision: 302 with no caching; 301 is opt-in per link.
  • Why: disable, expiry and analytics requirements all need every click to reach the service. The cost is higher redirect traffic, which the cache absorbs.

DR-3: PostgreSQL before a NoSQL key-value store

  • Decision: keep links in PostgreSQL until stage 5.
  • Why: 600 GB over five years fits one well-provisioned primary; we get constraints for aliases, transactions and simple operations. A key-value store (DynamoDB, Cassandra) becomes attractive only when one primary cannot absorb the writes, which at 4 writes/s is far away.

9. Limitations, and implemented versus proposed

Known limitations

  • Click counts are approximate by design.
  • A 301 link that is later disabled keeps redirecting in browsers that cached it.
  • Re-scanning destinations daily leaves a window in which a newly malicious site is still served.

Status

Item Status
Requirements, capacity, HLD, APIs, deployment Designed here
Class, ER, sequence and state diagrams; ID generation and encoding Designed here
Failure scenarios, caching, idempotency, security, scaling, SLOs Designed here
Decision records, trade-offs, limitations Designed here
Java implementation (redirect and link API) To build
Unit and integration tests To build
Hit-versus-miss benchmark and degraded-mode run To measure