Design a Distributed URL Shortener (TinyURL)
1. Problem Statement & Scope
System Mission
Design a highly scalable, fault-tolerant, low-latency URL Shortening service (similar to TinyURL or Bitly) that transforms long HTTP URLs into compact 7-character aliases, handles billions of redirects with sub-10ms latency, supports custom aliases, expiration TTLs, and real-time click analytics.
Functional Requirements
- URL Shortening (
POST /v1/urls): Given a long URL, generate a compact, unique 7-character alias (e.g.https://hi.link/aZ9k2Lq). - URL Redirection (
GET /{short_code}): Given a short code, instantly redirect the client to the original long URL with minimum latency. - Custom Aliases & Expiration: Allow users to specify custom short aliases (e.g.
https://hi.link/summit2026) and optional expiration timestamps (TTL). - Click Analytics: Asynchronously aggregate click metrics (total clicks, referrer, client country, timestamp).
Non-Functional Requirements (SLAs/SLOs)
- High Availability: uptime SLA for redirection lookups (Read Path is mission-critical).
- Ultra-Low Latency: P99 Read Redirection Latency ; P99 Write Latency .
- Durability: (11 9s) zero URL mapping loss guarantee.
- Read-to-Write Ratio: Heavy read bias of (100 redirections per 1 new URL shortened).
2. Capacity & Scale Estimation
Traffic Calculations
- New URL Creation Rate (Writes):
- 100 Million new URLs created per month.
- Average Write QPS:
- Peak Write QPS ( burst): .
- Redirection Traffic (Reads at 100:1 Ratio):
- Average Read QPS:
- Peak Read QPS ( burst): .
Storage & Memory Estimation (10-Year Horizon)
- 10-Year URL Volume:
- Record Size:
short_code: 7 Bytes.long_url: 500 Bytes average.user_id: 16 Bytes.created_at/expires_at: 16 Bytes.- Metadata & Overhead: 61 Bytes.
- Total per Record: .
- 10-Year Database Storage:
- 3-AZ Multi-Region Replicated Storage:
- In-Memory Cache Sizing (80/20 Pareto Rule):
- Daily active redirection requests: .
- of URLs generate of traffic. Even in the worst case where every hot request targets a distinct URL, the hot set is bounded by URLs:
- Allocate an Amazon ElastiCache Redis Cluster with (easily caching 100% of daily hot URLs).
Base62 Encoding & Hash Space Math
A short code consisting of alphanumeric characters [0-9, a-z, A-Z] has a character alphabet size of:
For a 7-character string, the total unique URL capacity is:
At URLs/month, provides over 2,900 years of collision-free namespace capacity.
Why the ID source must be a -bit counter, not a 64-bit Snowflake ID: . A 64-bit Snowflake ID (values up to ) encodes to 11 Base62 characters, not 7, so "Snowflake 7-char code" cannot work. This design therefore draws IDs from a monotonic counter (DynamoDB atomic counter, leased to each writer in blocks of 1,000 so the hot path never touches the counter row) and scrambles them inside the space (Section 6.1). Snowflake remains the right choice when 11-character codes are acceptable or when you need timestamp-sortable IDs.
3. AWS-First High-Level Architecture
Synthesizing vector architecture diagram...
Follow the two paths from the top. Reads (redirects): CloudFront answers most of them from its cache; a miss goes to the redirect Lambda, which checks Redis first (1) and DynamoDB only on a Redis miss (2), and every click is also sent asynchronously to Kinesis, so analytics never slow the redirect. Writes (new short links): the creator Lambda takes a block of 1,000 IDs from a DynamoDB atomic counter at once, then hands them out from memory, so it contacts the counter only once per 1,000 links; each ID is encoded into a short code and the mapping is saved to DynamoDB. In the "Asynchronous Click Stream Processing" panel, Firehose batches click events into Parquet files in S3 for Athena. Reads outnumber writes by about 100 to 1, so the design puts caches in front of reads and batching on writes.
Data Flow Walkthrough
- Creation & Bijective Obfuscation: Client submits
POST /v1/urls. URL Creator Lambda takes the next integer from its locally leased ID block (blocks of 1,000 are reserved with one DynamoDBADD counter :1000call, so the counter row sees writes/sec, not 38), applies a deterministic Feistel cipher permutation over the space to prevent sequential guessing, and converts the integer into a compact 7-character Base62 alias (hi.link/aZ9k2Lq). - Global Single-Table Persistence: The mapping is saved to Amazon DynamoDB Global Tables with primary key
URL#<short_code>and optional TTL. Custom aliases use conditional expressions (attribute_not_exists(PK)) to guarantee uniqueness. - Multi-Tier Edge & Redis Redirection: Redirection queries (
GET /{short_code}) resolve at CloudFront Edge caches (). On edge miss, regional Lambda checks ElastiCache Redis cluster (). On Redis miss, a single-flight mutex fetches from DynamoDB and backfills Redis. - Decoupled Asynchronous Analytics: The Redirect Lambda fires a non-blocking click event to Amazon Kinesis Data Streams and returns HTTP 302 immediately. Kinesis Data Firehose batches analytics into S3 Parquet lakes for ad-hoc SQL queries via Amazon Athena.
Base62 Namespace Longevity: A 7-character Base62 string yields () unique identifiers. At an ingestion rate of URLs per month, this namespace provides over 2,900 years of collision-free operational capacity without requiring recycling or hash collision resolution loops.
Concrete Step-by-Step Request Walkthrough: Tracing Redirection & Click Stream Ingestion
| Step # | Event / Action | Component State | Distributed Transition | Output / Response |
|---|---|---|---|---|
| 1 | User clicks link https://hi.link/aZ9k2Lq | Route 53 routes client to nearest CloudFront Edge PoP | CloudFront checks local Edge Cache for key /aZ9k2Lq;Edge Cache Miss | Request forwarded to Regional API Gateway |
| 2 | API Gateway invokes URL Redirect Lambda | Container re-uses warm Redis client connection pool | Lambda executes GET url:aZ9k2Lq against Amazon ElastiCache Redis | Redis cache hit occurs (); returns target destination URL |
| 3 | Lambda validates item expiration metadata | In-memory evaluation: currentTime < expires_at | URL confirmed valid and unexpired; HTTP 302 redirect response constructed | Destination Location header populated;Cache-Control: s-maxage=86400 set |
| 4 | Lambda dispatches asynchronous click record | Local fire-and-forget background worker thread | Asynchronous PutRecord to Amazon Kinesis Data Stream (partition_key = "aZ9k2Lq") | Async ingest completed in ; read path is completely non-blocking |
| 5 | Redirection returned to client | CloudFront caches response at Edge for subsequent visitors (s-maxage=86400, max-age=0 so browsers do not cache it) | HTTP 302 sent to browser; browser redirects user to destination | Destination page loads; P99 redirect latency: |
| 6 | Next visitor hits the same link at the same PoP | CloudFront Edge Cache Hit: the Lambda is never invoked, so it cannot emit a click event | CloudFront real-time logs stream every edge hit (path, referrer, viewer country, timestamp) to the same Kinesis Data Stream | Click captured with zero origin load; analytics stay 100% complete despite edge caching |
4. API Interface Design
1. Create Short URL
httpPOST /v1/urls Host: api.hi.link Content-Type: application/json Authorization: Bearer <jwt_token> { "long_url": "https://www.amazon.com/dp/B08N5WRWNW?ref=my_campaign_summer_sale_2026_promo_code_xyz", "custom_alias": "summer-sale-2026", "ttl_seconds": 2592000 } Response: 201 Created { "short_code": "summer-sale-2026", "short_url": "https://hi.link/summer-sale-2026", "long_url": "https://www.amazon.com/dp/B08N5WRWNW?ref=my_campaign_summer_sale_2026_promo_code_xyz", "created_at": 1718000000, "expires_at": 1720592000 }
2. URL Redirection Endpoint
httpGET /{short_code} Host: hi.link Response: 302 Found (301 Moved Permanently only for immutable, analytics-free links) Location: https://www.amazon.com/dp/B08N5WRWNW?ref=my_campaign_summer_sale_2026_promo_code_xyz Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=3600
max-age=0 keeps the browser from caching the redirect (so revocations take effect on the next click), while s-maxage=86400 lets CloudFront serve it from the edge. Edge hits never reach the origin, so click analytics for them come from CloudFront real-time logs streamed into the same Kinesis stream the Lambda writes to (Section 3, step 6).
3. Link Analytics
httpGET /v1/urls/{short_code}/stats?window=7d Host: api.hi.link Authorization: Bearer <jwt_token> Response: 200 OK { "short_code": "summer-sale-2026", "window": "7d", "total_clicks": 14290, "unique_visitors_approx": 11804, "top_referrers": [{"host": "t.co", "clicks": 6120}, {"host": "news.ycombinator.com", "clicks": 2210}], "top_countries": [{"country": "US", "clicks": 8010}, {"country": "DE", "clicks": 1330}], "updated_at": 1718003600 }
Served from the pre-aggregated click_stats table below (refreshed every 60 s by the stream aggregator), never from the raw event lake, so the request costs one DynamoDB read.
HTTP 301 (Moved Permanently) vs. HTTP 302 (Found / Temporary Redirect):
- HTTP 301: The browser caches the redirection locally. Subsequent clicks go straight to the destination server without hitting
hi.link. This minimizes redirection latency and server load, but prevents real-time click tracking. - HTTP 302: The browser always sends the request to
hi.linkfirst, enabling 100% accurate click analytics and abuse filtering at the cost of slightly higher server traffic.
5. Data Models & Storage Architecture
DynamoDB Single-Table Schema (UrlShortenerTable)
- Primary Partition Key (
PK):URL#<short_code> - Primary Sort Key (
SK):METADATA - Global Secondary Index (
GSI1):GSI1-PK: USER#<user_id>,GSI1-SK: CREATED#<created_at>
| Field | Type | Description | Example |
|---|---|---|---|
PK | String (Hash) | Partition Key | URL#aZ9k2Lq |
SK | String (Range) | Sort Key | METADATA |
long_url | String | Original Target URL | https://amazon.com/... |
user_id | String | Creator User ID | usr_84920 |
created_at | Number | Unix Timestamp (Epoch s) | 1718000000 |
expires_at | Number (TTL) | DynamoDB Native TTL Attribute | 1720592000 |
safety_status | String | OK / BLOCKED (set by the async URL scanner, Section 8.4) | OK |
click_count | Number | Denormalized total, written only by the stream aggregator every 60 s (never on the read path, see Section 9.1) | 14290 |
Redis Key Schema (ElastiCache)
| Key Pattern | Value | TTL | Notes |
|---|---|---|---|
url:{short_code} | long_url (string) | Filled on DynamoDB miss (SETEX); evicted on quarantine or expiry | |
neg:{short_code} | 1 | Negative cache: stops scanners hammering DynamoDB with random non-existent codes | |
id_block:{writer_id} | {next, end} | none | Locally leased ID range for the creator Lambda (Section 6.1) |
Click Analytics Storage (Two Tiers)
| Store | Schema | Purpose |
|---|---|---|
S3 Parquet lake (s3://clicks/dt=YYYY-MM-DD/hour=HH/) | short_code, ts, referrer_host, country, user_agent_class, edge_pop | Raw, append-only event log for ad-hoc Athena SQL; partitioned by day and hour |
DynamoDB click_stats (PK = URL#<code>, SK = WINDOW#7d) | total_clicks, unique_hll (HyperLogLog sketch), top_referrers[], top_countries[], updated_at | Pre-aggregated counters the /stats endpoint reads in one GetItem |
Unlock Complete Architecture & Production Runbooks
You have explored the free architectural preview (~44%). Spend 1 Coin to unlock the remaining 6 production deep-dive sections for a full 24 hours.