Redirects outnumber creations 300 to 1 and sit on every click path, so reads must answer in milliseconds.
URL Shortener
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
flowchart LR U[Browser] --> CDN[Edge / CDN] CDN --> LB[Load balancer] LB --> R[Redirect service] LB --> C[Link API] R --> RC[(Redis cache)] R -->|miss| RR[(PostgreSQL replica)] C --> PG[(PostgreSQL primary)] C --> RC C --> SB[Safe Browsing check] PG -. replication .-> RR R -->|click event, async| K[[Kafka: clicks]] K --> AG[Analytics aggregator] AG --> AS[(Analytics store)]
| 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
flowchart LR
subgraph Edge
CDN[CDN: static 404 page, TLS, DDoS protection]
end
subgraph App [Kubernetes or VMs]
R1[redirect x N, autoscaled on CPU]
C1[link-api x 2]
A1[aggregator x 2]
end
subgraph Data
RD[(Redis primary + replica)]
P[(PostgreSQL primary)]
PR[(PostgreSQL read replica)]
KF[[Kafka, 3 brokers]]
end
CDN --> R1
CDN --> C1
R1 --> RD
R1 --> PR
C1 --> P
P -.-> PR
R1 --> KF
KF --> A1
4. Internal design and workflows
Class diagram
classDiagram
class UrlMapping {
+code
+destination
+ownerId
+createdAt
+expiresAt
+status LinkStatus
+isRedirectable(now) bool
}
class UrlShortenerService {
+create(cmd) UrlMapping
+disable(code, owner)
+delete(code, owner)
}
class RedirectService {
+resolve(code) Resolution
}
class IdGenerator {
<<interface>>
+next() long
}
class BlockIdGenerator {
-current long
-end long
+next() long
}
class Base62Encoder {
+encode(long) String
+decode(String) long
}
class UrlRepository {
<<interface>>
+insert(mapping)
+findByCode(code) Optional~UrlMapping~
}
class LinkCache {
+get(code) CacheEntry
+put(code, entry, ttl)
+putMissing(code)
+evict(code)
}
class DestinationPolicy {
+check(url) Verdict
}
class ClickPublisher {
+publish(ClickEvent)
}
UrlShortenerService --> IdGenerator
UrlShortenerService --> Base62Encoder
UrlShortenerService --> UrlRepository
UrlShortenerService --> DestinationPolicy
UrlShortenerService --> LinkCache
RedirectService --> LinkCache
RedirectService --> UrlRepository
RedirectService --> ClickPublisher
IdGenerator <|.. BlockIdGenerator
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:
- 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. - It hands out IDs from the block in memory. No database round trip per link, no collisions across instances.
- 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
aZ3x9Qpreveals nothing aboutaZ3x9Qq. - 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
erDiagram
LINK ||--o{ CLICK_DAILY : "aggregated into"
LINK {
text code PK
text destination
uuid owner_id
timestamptz created_at
timestamptz expires_at
text status
bool permanent
}
CLICK_DAILY {
text code PK
date day PK
bigint clicks
jsonb top_referrers
jsonb top_countries
}
ID_BLOCK {
text name PK
bigint next
}
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)
sequenceDiagram
participant B as Browser
participant R as RedirectService
participant C as Redis
participant D as PG replica
participant K as Kafka
B->>R: GET /aZ3x9Qp
R->>C: GET link:aZ3x9Qp
alt cache hit (link)
C-->>R: destination, status, expiresAt
else cache hit (missing marker)
C-->>R: MISSING
R-->>B: 404
else cache miss
R->>D: SELECT ... WHERE code = ?
alt found
D-->>R: row
R->>C: SET link:code (TTL = min(1 h, expiresAt - now))
else not found
R->>C: SET link:code MISSING (TTL 60 s)
R-->>B: 404
end
end
R->>R: isRedirectable(now)? else 410
R--)K: publish ClickEvent (fire and forget, buffered)
R-->>B: 302 Location: destination
Create sequence
sequenceDiagram
participant U as User
participant A as Link API
participant P as DestinationPolicy
participant D as PG primary
participant C as Redis
U->>A: POST /api/links (url, alias?, Idempotency-Key)
A->>A: validate URL (scheme http/https, length, not our own domain)
A->>P: check(url): Safe Browsing + blocklist
P-->>A: SAFE
A->>A: code = alias or Base62(permute(nextId()))
A->>D: INSERT link (PK on code)
alt alias taken
D-->>A: unique violation
A-->>U: 409 ALIAS_TAKEN
else inserted
A->>C: DEL link:code (clears any MISSING marker)
A-->>U: 201
end
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.
Link states
stateDiagram-v2 [*] --> ACTIVE: created ACTIVE --> DISABLED: owner disables DISABLED --> ACTIVE: owner re-enables ACTIVE --> EXPIRED: expires_at passed ACTIVE --> DELETED: owner deletes DISABLED --> DELETED: owner deletes EXPIRED --> DELETED: owner deletes DELETED --> [*]
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
linktable 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
DELon 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
httpandhttpsdestinations are allowed;javascript:,data:andfile: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}/statsis 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:
302with no caching;301is 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
301link 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 |