URL Shortener: Deep Dives
What ID generation strategy should we use, and why?
Level: Mid-level
The ID must have two properties: global uniqueness (no two URLs share a code) and shortness (compact enough to be useful as a short URL). These properties are in tension — longer IDs are easier to make unique; shorter IDs risk collisions.
The fundamental approach: generate a unique integer, then encode it into a compact string. Let's evaluate the integer generation options:
Option 1: Hash Functions (MD5 / SHA-256)
Hash the original URL to produce a fixed-length digest, then truncate to the desired length.
| Hash | Output Size | Truncated to 6 chars | Collision Risk |
|---|---|---|---|
| MD5 | 128 bits (32 hex chars) | First 6 hex chars → 16⁶ = 16.7M IDs | HIGH — birthday paradox: 50% collision at ~4,800 URLs |
| SHA-256 | 256 bits (64 hex chars) | First 6 Base62 chars → 56.8B IDs | Still requires collision detection |
Problem: Truncation dramatically increases collision probability. Even with collision detection and retry logic, the write path becomes: hash → check DB → collision? → re-hash with salt → check again. Under load, this creates hot-path database reads on every write.
Collision Math: Birthday Paradox
The birthday paradox tells us collisions appear much sooner than expected. For a hash space of size N, the probability of at least one collision after k insertions is approximately:
For 6 hex characters (N = 16⁶ = 16,777,216):
- After 1,000 URLs: P ≈ 3%
- After 4,800 URLs: P ≈ 50%
- After 10,000 URLs: P ≈ 95%
For 6 Base62 characters (N = 62⁶ = 56,800,235,584):
- After 100,000 URLs: P ≈ 8%
- After 280,000 URLs: P ≈ 50%
- After 1,000,000 URLs: P ≈ 99.98%
Even with the larger Base62 space, a collision becomes more likely than not after about 280,000 URLs — under 7 hours of traffic at 1M new URLs per day. Hash-based IDs need collision detection from day one.
Option 2: UUID (v4 Random / v1 Timestamp)
| UUID Version | Format | Length | Uniqueness |
|---|---|---|---|
| UUIDv4 | Random 122-bit | 36 chars (f47ac10b-58cc-4372-...) | Extremely unlikely collisions |
| UUIDv1 | Timestamp + MAC | 36 chars | Guaranteed unique per machine |
Problem: 36 characters defeats the purpose of URL shortening. Even Base62-encoding a UUIDv4 produces ~22 characters. UUIDs solve uniqueness but fail the shortness requirement.
Option 3: Snowflake IDs (Twitter-style)
Structure: [timestamp: 41 bits][machine_id: 10 bits][sequence: 12 bits] = 64-bit integer.
| Property | Value |
|---|---|
| Total bits | 64 |
| Base62 encoded length | ~11 characters |
| Max machines | 1,024 |
| Max IDs per ms per machine | 4,096 |
Problem: Base62 encoding of a 64-bit integer produces ~11 characters. Still too long for a "short" URL. We could truncate, but that reintroduces collision risk.
Option 4: Machine ID + Sequence Number (Recommended)
Assign each server a unique 1-character prefix. Each server maintains an auto-incrementing counter. The ID is: MachinePrefix + SequenceNumber.
With 1 character for machine ID (62 options using Base62) and 5 characters for sequence (62⁵ = 916M per machine):
| Component | Bits/Chars | Capacity |
|---|---|---|
| Machine prefix | 1 Base62 char | 62 machines |
| Sequence number | 5 Base62 chars | 916M per machine |
| Total capacity | 6 chars | 62 × 916M = 56.8B |
import os
CHECKPOINT_EVERY = 1_000
class IDGenerator:
def __init__(self, machine_id: str):
"""
machine_id: single Base62 character ('0' through 'z')
"""
assert len(machine_id) == 1
self.machine_id = machine_id
self.sequence = self._load_last_sequence() # Recover after restarts
def next_id(self) -> str:
self.sequence += 1
if self.sequence % CHECKPOINT_EVERY == 0:
self._persist_sequence() # Checkpoint every 1,000 IDs
encoded_seq = base62_encode(self.sequence).zfill(5) # Pad to 5 chars ('0' is the zero digit)
short_code = self.machine_id + encoded_seq
return short_code
def _load_last_sequence(self) -> int:
"""Resume past the last checkpoint: up to 1,000 IDs after it may already be in use."""
try:
with open(f"/var/data/sequence_{self.machine_id}.txt") as f:
return int(f.read().strip()) + CHECKPOINT_EVERY
except FileNotFoundError:
return 0
def _persist_sequence(self):
"""Write the checkpoint atomically, so a crash mid-write cannot corrupt it."""
path = f"/var/data/sequence_{self.machine_id}.txt"
with open(path + ".tmp", "w") as f:
f.write(str(self.sequence))
os.replace(path + ".tmp", path)Advantages over alternatives:
| Strategy | Length | Uniqueness | Coordination | Complexity |
|---|---|---|---|---|
| Hash + truncation | 6+ | Requires collision detection | None | Medium (retry logic) |
| UUID | 22+ (Base62) | Guaranteed | None | Low |
| Snowflake | 11+ (Base62) | Guaranteed | Clock sync | Medium |
| Machine ID + Sequence | 6 | Guaranteed (by construction) | Machine ID assignment | Low |
The Machine ID + Sequence approach produces the shortest IDs with guaranteed uniqueness and no runtime coordination between servers. The only coordination point is machine ID assignment — a one-time setup operation when adding servers.
Handling Server Restarts and Sequence Recovery
If a server crashes and restarts, it must not reuse sequence numbers. Three approaches:
-
Persistent checkpoint (recommended): Write the current sequence to disk every N IDs (e.g., every 1,000). On restart, resume from the last checkpoint + 1,000 (skip the gap to avoid any overlap).
-
Database-backed sequence: Use a database counter (
SELECT last_seq FROM sequences WHERE machine_id = 'A' FOR UPDATE). Reliable but adds a DB call to every write. -
Time-based offset: On restart, set the sequence to
(seconds since the service launched) × (the machine's maximum ID rate). As long as the machine never issues IDs faster than that rate, this is always past every ID already issued. It needs no persistent state, but it burns ID space quickly, so it only suits machines with a low maximum rate.
The checkpoint approach balances reliability with performance — you lose at most 1,000 IDs per crash (0.0001% of the per-machine capacity of 916M).
Pre-generating IDs in Bulk
Under sudden traffic spikes, generating IDs on-demand could introduce latency if the sequence checkpoint or encoding becomes a bottleneck. Pre-generating a batch of IDs eliminates this concern:
class BulkIDGenerator(IDGenerator):
BATCH_SIZE = 10_000
def __init__(self, machine_id: str):
self.machine_id = machine_id
self.buffer = []
self.sequence = self._load_last_sequence()
self._refill()
def next_id(self) -> str:
if not self.buffer:
self._refill()
return self.buffer.pop()
def _refill(self):
batch = []
for _ in range(self.BATCH_SIZE):
self.sequence += 1
batch.append(self.machine_id + base62_encode(self.sequence).zfill(5))
self.buffer = batch
self._persist_sequence()Trade-off: Pre-generation wastes IDs if the server crashes before using all buffered IDs. With a 10K buffer and 916M capacity per machine, this waste is negligible (0.001%).
How should we encode IDs into short, URL-safe strings?
Level: Mid-level
After generating a unique integer, we need to encode it into a compact, human-readable string. The encoding must balance shortness with usability — avoiding characters that are confusing to type or problematic in URLs.
Encoding Options Comparison
| Encoding | Characters | Capacity (6 chars) | Chars for 56B IDs | Issues |
|---|---|---|---|---|
| Base16 (Hex) | 0-9, a-f | 16⁶ = 16.7M | 9 chars | Only 16 chars → longer strings |
| Base36 | 0-9, a-z | 36⁶ = 2.2B | 7 chars | Case-insensitive (loses half the alphabet) |
| Base58 | A-Z, a-z, 0-9 minus 0OIl | 58⁶ = 38B | 7 chars | Avoids confusing characters but non-standard |
| Base62 | a-z, A-Z, 0-9 | 62⁶ = 56.8B | 6 chars | No special characters, URL-safe |
| Base64 | A-Z, a-z, 0-9, +, /, = | 64⁶ = 68.7B | 6 chars | +, /, = problematic in URLs |
Why Base62?
Base62 is the optimal choice because:
- No special characters — Base64's
/splits a URL path, and+and=are often rewritten by encoders, so all three end up percent-encoded (%2F,%2B,%3D), defeating the purpose of shortening. URL-safe Base64 swaps them for-and_, but those are easy to lose when a link is underlined or wrapped at a line end, so shorteners usually stick to letters and digits - 6 characters suffice — 62⁶ = 56.8B provides 30× headroom over our 5-year requirement
- Human-friendly — All characters are easily typeable on any keyboard
- Case-sensitive by definition — URLs are case-sensitive after the domain, so
a1B2c3andA1b2C3are different short codes
Why Not Base58? (Bitcoin's Choice)
Base58 removes visually ambiguous characters: 0 (zero) vs O (letter O), I (uppercase i) vs l (lowercase L). This is valuable for Bitcoin addresses that are manually transcribed.
For URLs, visual ambiguity matters less — users click links, they don't type them. The extra 4 characters (0, O, I, l) give us Base62's larger space (56.8B vs 38B for same length), reducing the likelihood of needing 7-character codes.
When to choose Base58: If your short URLs appear on billboards, business cards, or physical media where humans must type them. When to choose Base62: If URLs are primarily shared digitally (links, messages, emails).

How do we scale the system to handle high traffic?
Level: Senior
Request handlers are stateless HTTP servers — scaling them is trivial (add more instances behind the load balancer). The interesting scaling challenges are in the ID generator and the database.
Scaling the ID Generator
Each ID generator uses 1 character for machine ID, giving us 62 possible machines. Each machine generates up to 916M IDs. But how do we route requests to them?
Load balancing strategies:
- Random selection: Each request handler picks a random ID generator. Simplest, good load distribution.
- Least-loaded: Pick the generator with the shortest queue. Better under skewed load.
- Sticky routing: Route by client ID. Unnecessary (any generator works for any request).
Sharding the Database
At our estimates (12 writes/sec, about 1 TB over five years), one PostgreSQL primary with read replicas is enough: the cache absorbs most reads, and the writes are light. Sharding becomes necessary when the data or the write rate outgrows one primary, for example at 100× today's volume. When that happens, this design already has a natural shard key: the machine ID prefix in each short code serves double duty as the shard key. Each machine writes to its own database shard — the write paths are completely independent.
Shard key = Machine ID prefix in the short code.
When the machine with prefix a generates code a1B2c3, it writes to shard a. When the machine with prefix B generates B7x9pQ, it writes to shard B. No cross-shard coordination is needed for writes.

Write path: Completely parallel. Each machine writes to its own shard. No coordination, no locking, no cross-shard transactions. Adding a new machine = adding a new shard. Existing shards are unaffected.
Read path: On a cache miss, the redirect handler extracts the first character of the short code and routes to the correct shard. For example, a1B2c3 → shard a.
Why This Sharding Strategy Works
| Benefit | Explanation |
|---|---|
| No rebalancing | Unlike consistent hashing, adding a new shard doesn't redistribute existing data. All old data stays where it is. |
| Deterministic routing | The shard is encoded in the short code itself. No routing table lookup needed. |
| Independent scaling | If shard a gets hot, add a read replica. Other shards are unaffected. |
| Simple failure isolation | If shard B fails, only codes starting with B are affected. All other codes work fine. |
Scaling Request Handlers and ID Generators Independently
Should request handlers and ID generators be 1:1? No — they have different scaling profiles:
Request handlers are I/O-bound: handling HTTP connections, checking cache, holding sockets open. They need many instances for concurrency — often 10-50 instances for high traffic.
ID generators are CPU/IO-bound: generating IDs and writing to disk/DB. Each generator can handle thousands of IDs per second. At 12 QPS average, even a single generator has enormous headroom.
Typical ratio: 10-20 request handlers per ID generator.
Routing: Request handlers pick an ID generator using round-robin or least-loaded selection. Since any generator can serve any request, the routing doesn't need to be sticky.
Database Choice: DynamoDB vs Cassandra vs PostgreSQL
| Database | Strengths | Weaknesses | Best For |
|---|---|---|---|
| DynamoDB | Managed, auto-scales, single-digit ms latency, built-in partitioning | Expensive at high throughput, limited query flexibility | AWS-native teams, low operational overhead |
| Cassandra | Linear horizontal scaling, tunable consistency, handles massive write throughput | Operational complexity, no transactions, limited secondary indexes | Write-heavy workloads, multi-region |
| PostgreSQL (sharded) | Rich query support, ACID transactions, familiar tooling | Manual sharding, scaling requires application-level routing | Teams with PostgreSQL expertise, analytics queries |
Recommended for this design: PostgreSQL. The tables, queries and replication in this lesson are PostgreSQL: one primary with read replicas carries today's load, and sharding by the code's prefix is the path when one primary is outgrown. DynamoDB is the strongest alternative for AWS teams that want no database operations: the access pattern (a key-value lookup by short code) fits it well, and it partitions data automatically, so no manual sharding is needed.
DynamoDB schema (the alternative):
Table: url_mappings
Partition Key: short_code (String)
Attributes: original_url (String), created_at (Number), is_active (Boolean)
Read Capacity: On-demand (auto-scales)
Write Capacity: On-demand
How do we design the caching layer for 90%+ hit rates?
Level: Senior
Caching is the single most impactful design decision for a URL shortener. With a 100:1 read-write ratio, the difference between an 80% and a 95% cache hit rate is 20% vs 5% of redirects reaching the database: four times the database load.
Traffic Distribution: Zipf's Law
URL access follows a Zipfian distribution — a small fraction of URLs receive the vast majority of clicks. A typical shape:
- Top 1% of URLs → ~50% of all clicks
- Top 10% of URLs → ~90% of all clicks
- Bottom 50% of URLs → ~1% of clicks (many are clicked only once)
This is excellent for caching: a small cache capturing the top URLs handles most traffic.
Cache Sizing
| Cache Size | URLs Cached | Estimated Hit Rate | DB QPS (at 1,160 total) |
|---|---|---|---|
| 100 MB | ~200K URLs | ~75% | 290 |
| 500 MB | ~1M URLs | ~88% | 139 |
| 1 GB | ~2M URLs | ~93% | 81 |
| 5 GB | ~10M URLs | ~98% | 23 |
At 1 GB (easily fits in a single Redis instance), we achieve ~93% hit rate, leaving only ~80 QPS hitting the database. This is well within a single database node's capacity.
Caching Strategies Compared
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Cache-aside | App checks cache → miss → app queries DB → app writes cache | Simple, app controls logic | Cache miss requires 2 round trips |
| Read-through | App reads cache → miss → cache auto-fetches from DB | Transparent to app, fewer code paths | Requires cache library support |
| Write-through | On URL creation, write to both DB and cache | Cache is always warm for new URLs | Adds latency to write path |
| Write-behind | On URL creation, write to cache first, async to DB | Lowest write latency | Risk of data loss if cache crashes |
Recommended: Read-through + Write-through hybrid.
- Write path: When a new URL is created, write to both DB and cache (write-through). This ensures newly created URLs are immediately resolvable without a cache miss.
- Read path: On redirect, read from cache. On miss, the cache auto-fetches from DB (read-through).
Cache Stampede and Thundering Herd Prevention
Cache stampede: A popular URL's cache entry expires. 1,000 concurrent requests all experience a cache miss simultaneously. All 1,000 hit the database for the same URL. The database is overwhelmed.
Solution: Request coalescing (singleflight pattern)
import asyncio
class CoalescingCache:
def __init__(self, redis_client, db_client):
self.redis = redis_client
self.db = db_client
self._inflight = {} # short_code → Future
async def get(self, short_code: str) -> str:
# Check cache first
result = await self.redis.get(f"url:{short_code}")
if result:
return result
# Coalesce concurrent misses for the same key
if short_code in self._inflight:
return await self._inflight[short_code]
future = asyncio.get_running_loop().create_future()
self._inflight[short_code] = future
try:
# Only ONE request hits the database
url = await self.db.get(short_code)
if url:
await self.redis.set(f"url:{short_code}", url, ex=86400)
future.set_result(url)
return url
except Exception as exc:
future.set_exception(exc) # Waiters get the error instead of hanging
raise
finally:
del self._inflight[short_code]With coalescing, 1,000 concurrent requests for the same expired key result in 1 database query instead of 1,000. The first request fetches from DB; the other 999 wait on the same Future.
Stale-while-revalidate: An alternative approach: serve the stale cached value immediately while refreshing in the background. This provides zero-latency cache misses at the cost of serving slightly stale data (acceptable for URL mappings that never change).
Multi-Tier Caching
For extreme scale (10B+ redirects/day), add a local in-process cache layer:
Tier 1: In-process LRU cache (each Redirect Handler instance)
- Size: 100 MB per instance (~200K URLs)
- Latency: < 0.1 ms (no network call)
- Hit rate: ~60% (top URLs accessed by this specific instance)
Tier 2: Redis cluster (shared)
- Size: 1-5 GB
- Latency: ~1 ms
- Hit rate: ~33% of all requests (93% cumulative with Tier 1)
Tier 3: Database
- Latency: ~5-10 ms
- Only ~7% of requests reach this tier
async def resolve_url(short_code: str) -> str:
# Tier 1: In-process cache (no network)
url = local_cache.get(short_code)
if url:
return url
# Tier 2: Redis (1ms network)
url = await redis.get(f"url:{short_code}")
if url:
local_cache.put(short_code, url)
return url
# Tier 3: Database (5-10ms)
url = await db.get_url(short_code)
if url:
await redis.set(f"url:{short_code}", url, ex=86400)
local_cache.put(short_code, url)
return urlShould we use 301 or 302 redirects? What are the trade-offs?
Level: Mid-level
The redirect status code determines browser behavior, caching semantics, and analytics accuracy. This seemingly small decision has significant downstream consequences.
| Status Code | Name | Browser Behavior | Cache-Control |
|---|---|---|---|
| 301 | Moved Permanently | Browser caches redirect forever; future visits bypass server | Cached by default |
| 302 | Found | Browser does NOT cache redirect; every visit hits server | Not cached by default |
| 307 | Temporary Redirect | Like 302 but preserves HTTP method (POST stays POST) | Not cached |
| 308 | Permanent Redirect | Like 301 but preserves HTTP method | Cached |
Impact Analysis
| Dimension | 301 (Permanent) | 302 (Temporary) |
|---|---|---|
| Server load | Lower — browser caches, fewer requests hit server | Higher — every click hits server |
| Analytics accuracy | Broken — cached redirects bypass server entirely, clicks go uncounted | Accurate — every click passes through server |
| URL deactivation | Broken — browser uses cached redirect even after deactivation | Works — server can return 410 Gone |
| SEO | Search engines index the destination | Search engines may keep indexing the short URL; link value still passes (Google: 3xx redirects don't lose PageRank) |
| User latency | Lower — cached redirect is instant | Slightly higher — server round-trip required |
When to Use Each
Use 302 (recommended for most URL shorteners):
- You need click analytics (the primary business model for many shorteners)
- You need ability to deactivate malicious URLs
- You want to A/B test destination URLs
- The redirect is for external/marketing links
Use 301 for specific cases:
- Internal tool links where analytics don't matter
- Links embedded in documentation that will never change
- When minimizing server load is critical and analytics are unnecessary
Hybrid Approach
A middle ground: use 302 with a Cache-Control header that allows short-term browser caching:
HTTP/1.1 302 Found
Location: https://example.com/destination
Cache-Control: private, max-age=300
This tells the browser: "Reuse this redirect for 5 minutes, then check back with us." Repeat clicks inside that window never reach the server, so you count at most one click per user per 5 minutes, and a deactivated link keeps working for that user until the cached copy expires. The longer the max-age, the bigger both effects: at max-age=86400, you count at most one click per user per day.
How do we prevent abuse and detect malicious URLs?
Level: Senior
URL shorteners are prime targets for abuse. Attackers use them to disguise phishing URLs, distribute malware, and bypass email security filters. If security vendors blocklist your domain, legitimate users lose access — a business-critical failure.
Defense Layers
Layer 1: Pre-creation scanning (synchronous)
Before storing a URL, check it against known malicious URL databases:
import aiohttp
SAFE_BROWSING_API = "https://safebrowsing.googleapis.com/v4/threatMatches:find?key=" + SAFE_BROWSING_KEY
async def check_url_safety(url: str) -> dict:
"""Check URL against Google Safe Browsing API."""
payload = {
"client": {"clientId": "short.ly", "clientVersion": "1.0"},
"threatInfo": {
"threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE"],
"platformTypes": ["ANY_PLATFORM"],
"threatEntryTypes": ["URL"],
"threatEntries": [{"url": url}]
}
}
async with aiohttp.ClientSession() as session:
async with session.post(SAFE_BROWSING_API, json=payload) as resp:
result = await resp.json()
if result.get("matches"):
return {"safe": False, "threats": result["matches"]}
return {"safe": True}
async def shorten_url(long_url: str, client_ip: str) -> str:
# Step 1: Rate limit by IP first: it is cheap, and it stops bulk creation of phishing URLs
if not rate_limiter.allow(client_ip, limit=100, window=3600):
raise RateLimitExceeded("Too many URLs created")
# Step 2: Check safety BEFORE creating the short URL
safety = await check_url_safety(long_url)
if not safety["safe"]:
raise MaliciousURLError("URL flagged by Safe Browsing API")
# Step 3: Create short URL
return create_short_url(long_url)Layer 2: Post-creation batch scanning (asynchronous)
URLs can become malicious after creation (e.g., destination changes, new threat intelligence). A background scanner rechecks URLs periodically:
| Scanning Strategy | Check Frequency | Coverage | Cost |
|---|---|---|---|
| New URLs (< 24h old) | Every 4 hours | Focus on fresh, unverified URLs | Low |
| Most-clicked URLs | Every 12 hours | High-impact URLs | Low |
| Random sample (1%) | Daily | Catch long-tail abuse | Low |
| Reported URLs | Immediately | User-triggered investigation | Reactive |
Layer 3: Enumeration prevention
Short codes like a00001, a00002, a00003 are sequential and predictable. An attacker can enumerate all URLs by incrementing the code. Mitigations:
- Randomize sequence within machine: Instead of strictly incrementing, use a permutation function that maps sequential integers to non-sequential outputs
- Rate limit redirect requests: Detect enumeration patterns (sequential codes from same IP)
- Don't expose creation-order information: Ensure short codes don't reveal when they were created
URL Deactivation and Takedown Process
When a malicious URL is detected:
- Immediate: Set
is_active = FALSEin the database - Cache invalidation: In every region's Redis, delete the cache entry (
DEL url:{short_code}) and set a permanent block marker; tell every Redirect Handler to drop the link from its in-process cache (a pub/sub message) - Redirect behavior change: Return
410 Gonewith a warning page instead of redirecting - Notification: Alert the URL creator (if authenticated) and the reporter (if user-reported)
async def deactivate_url(short_code: str, reason: str):
await db.execute(
"UPDATE url_mappings SET is_active = FALSE WHERE short_code = %s",
short_code
)
await redis.delete(f"url:{short_code}")
await redis.set(f"deactivated:{short_code}", reason) # No expiry: a deactivated link stays blocked
await audit_log.record(short_code, "DEACTIVATED", reason)
async def handle_redirect(short_code: str):
# Check deactivation first (cached check)
deactivation_reason = await redis.get(f"deactivated:{short_code}")
if deactivation_reason:
return render_warning_page(short_code, deactivation_reason)
url = await resolve_url(short_code)
if url:
await record_click(short_code) # Async analytics
return redirect(url, status=302)
return not_found()The block holds even when caches are rebuilt: every database lookup reads only active rows (WHERE short_code = %s AND is_active), so a cold cache cannot bring a deactivated link back.
Time to deactivation target: < 5 minutes from detection to global cache purge. This limits the exposure window for phishing campaigns.
How do we deploy across regions for global availability?
Level: Senior
A user in California creates a short URL. Seconds later, someone in Tokyo clicks it. The URL must resolve globally with minimal latency. This requires multi-region deployment with careful consistency trade-offs.

The Consistency Challenge
A newly created URL must be globally resolvable within seconds. But cross-region replication takes 100-300 ms. During this window, a request to the AP-EAST region returns 404 for a URL that exists in US-WEST.
Solution: Read-after-write consistency for creators
async def shorten_url(long_url: str) -> dict:
short_code = generate_id()
# Write to primary (US-WEST)
await primary_db.insert(short_code, long_url)
# Write-through to LOCAL cache (immediate availability in creation region)
await local_redis.set(f"url:{short_code}", long_url, ex=86400)
# Return short URL — creator can immediately share it
return {"shortUrl": f"https://short.ly/{short_code}"}The creator's region has the URL immediately in cache. Other regions receive it within 300 ms via replication. For the rare case where someone clicks a URL within 300 ms of creation from a different region:
Fallback: Cross-region read on miss
async def resolve_url(short_code: str) -> str:
# 1. Check local cache ("" marks a code recently found nowhere)
cached = await local_redis.get(f"url:{short_code}")
if cached is not None:
return cached or None
# 2. Check local DB replica
url = await local_db.get(short_code)
if url:
await local_redis.set(f"url:{short_code}", url, ex=86400)
return url
# 3. Fallback: check primary region (adds 100-200ms latency)
url = await primary_db.get(short_code)
if url:
await local_redis.set(f"url:{short_code}", url, ex=86400)
return url
# 4. Not found anywhere: remember that for 5 seconds (our global visibility target),
# so repeated lookups of a missing code stay local
await local_redis.set(f"url:{short_code}", "", ex=5)
return NoneThe cross-region fallback adds 100-200 ms. For real links it triggers only in the first ~300 ms after creation, before the link reaches the local replica. But every code that does not exist at all — typos, bots, enumeration attempts — also falls through to the primary region, which is why step 4 caches the miss. It keeps that answer for only 5 seconds: codes are sequential, so a code probed just before it is created must still become visible within our 5-second target. Probing many different codes is the rate limiter's job (see the abuse deep dive).
Regional Cache Warming
For high-profile URLs (e.g., a marketing campaign shared on social media), pre-warm caches in all regions at creation time:
| Strategy | When to Use | Latency Impact |
|---|---|---|
| Async replication only | Default (99% of URLs) | 100-300ms replication lag |
| Cross-region cache write | URLs created via API with priority: high flag | 50-100ms added to write path |
| Cross-region read fallback | Cache miss for very new URLs | 100-200ms for affected reads |
How do we handle URL expiration and storage growth?
Level: Mid-level
At 1M URLs/day for 5 years, we accumulate 1.825B records (~1 TB). Without cleanup, storage grows indefinitely. But deleting URLs is dangerous — a "dead link" is worse than a "slightly slow link."
Our requirements say a short URL works forever, and expiration was out of scope. At about 1 TB, keeping every link is affordable, and the storage tiering below keeps it cheap. So expiring links is a product decision (for example for free-tier or never-used links), not a storage necessity. If the product decides to expire links, this is how to do it safely.
Expiration Policy
| URL Category | Retention | Rationale |
|---|---|---|
| Active URLs (clicked in last 90 days) | Forever | Still in use, never expire |
| Dormant URLs (not clicked in 90 days) | 5 years from creation | Might be embedded in old documents |
| Orphan URLs (never clicked, > 1 year old) | 1 year from creation | Likely created by bots or forgotten |
| Deactivated URLs (malicious) | Keep record forever, redirect disabled | Prevent short code reuse |
TTL-Based Cleanup
def cleanup_expired_urls():
"""Runs daily as a scheduled batch job."""
# Never delete — archive and soft-delete
expired = db.query("""
SELECT m.short_code FROM url_mappings m
LEFT JOIN url_analytics a ON a.short_code = m.short_code
WHERE m.is_active = TRUE
AND (
-- Dormant: older than 5 years, not clicked in the last 90 days
(m.created_at < NOW() - INTERVAL '5 years'
AND (a.last_accessed IS NULL OR a.last_accessed < NOW() - INTERVAL '90 days'))
-- Orphan: older than 1 year, never clicked (no analytics row yet)
OR (m.created_at < NOW() - INTERVAL '1 year' AND a.short_code IS NULL)
)
""")
for batch in chunk(expired, 1000):
# Archive to cold storage (S3/Glacier)
archive_to_cold_storage(batch)
# Soft-delete
db.execute(
"UPDATE url_mappings SET is_active = FALSE WHERE short_code IN %s",
batch
)
# Invalidate cache (and every in-process cache, as for a deactivated link)
redis.delete(*[f"url:{code}" for code in batch])Storage Tiering
| Tier | Data | Storage | Cost |
|---|---|---|---|
| Hot (0-30 days) | Recent URLs + active cache | NVMe SSD + Redis | $$$ |
| Warm (30 days - 1 year) | Most URLs | SSD | $$ |
| Cold (1-5 years) | Dormant, rarely accessed | HDD or S3 | $ |
| Archive (5+ years) | Historical records | S3 Glacier | ¢ |
Key principle: Never delete a short code that might be embedded somewhere. Soft-delete, archive, and serve a "this link has expired" page instead of a 404: when a redirect finds no active link, check whether the code exists with is_active = FALSE, and if it does, show that page.