System Design Interview: How to Design a URL Shortener like bit.ly
Complete system design walkthrough for a URL shortener like bit.ly. Covers Base62 encoding, database schema, caching with Redis, custom aliases, expiry, analytics at scale, and global distribution.
To design a URL shortener like bit.ly, you need a service that maps a short 7-character code to an original long URL, handles billions of redirects per day with sub-50ms latency, caches hot URLs in Redis, and records click analytics asynchronously. The core algorithm is Base62 encoding of an auto-incrementing ID — no collisions, no DB checks, scales to 3.5 trillion unique URLs.
This walkthrough covers every layer: requirements, encoding algorithm, database schema, caching, custom aliases, expiry, scaling the redirect service, and analytics tracking — exactly the depth an interviewer expects.
Requirements: Functional and Non-Functional
Before designing anything, clarify scope. In an interview, ask these questions first:
Clarifying questions to ask the interviewer:
- Do users need accounts, or is shortening anonymous?
- Do we need custom aliases (e.g.,
/my-brand-name)? - Do shortened URLs expire, or live forever?
- Do we need click analytics (count, geo, device, referrer)?
- What is the expected scale — DAU, URLs per day, redirect ratio?
- Does the system need to be globally distributed?
Functional requirements:
- Shorten a URL:
POST /shorten→ returns a short code likexyz1234 - Redirect:
GET /xyz1234→ 301 or 302 redirect to the original URL - Custom aliases: allow users to choose their own slug (
/my-custom-slug) - Expiry: URLs can have an optional expiration date after which they return 410 Gone
- Analytics: track click count, country, device type, referrer
Non-functional requirements (target scale):
- 100M new URLs created per day
- Read-heavy: 100:1 read-to-write ratio → 10 billion redirects per day
- Redirect latency: < 50ms at p99
- High availability: 99.99% uptime (redirect service is critical path)
- Data retention: 5 years
Capacity Estimates
Turn the requirements into concrete numbers:
| Metric | Calculation | Result |
|---|---|---|
| Write rate | 100M / 86,400s | ~1,200 writes/sec |
| Read rate | 10B / 86,400s | ~115,000 reads/sec |
| Storage per URL | 500 bytes (URL + metadata) | — |
| Total storage | 100M × 500B × 365 × 5 | ~91 TB over 5 years |
| Cache storage | Top 20% of 100M URLs × 500B | ~10 GB (fits in RAM) |
The 100:1 read/write ratio is the defining characteristic. The redirect service is the hot path — it must be blazing fast. URL creation is the cold path — it can tolerate slightly higher latency.
Core Algorithm: URL Encoding
The central question in a URL shortener design: how do you generate a short, unique, URL-safe code for each long URL? There are three approaches. Pick the right one.
Option 1: MD5 Hash (Problematic)
import hashlib
import base64
def shorten_md5(url: str) -> str:
hash_bytes = hashlib.md5(url.encode()).digest()
# Take first 7 characters of base64-encoded hash
return base64.urlsafe_b64encode(hash_bytes)[:7].decode()
shorten_md5("https://example.com/very/long/url") # → "abc1234"Why this is flawed:
- Hash collisions: two different URLs can produce the same 7-character prefix. You must detect and resolve collisions, which adds a DB round-trip on every write.
- Deterministic output: the same URL always produces the same code. If two users shorten the same URL, they share analytics — usually undesirable.
- Hard to decode: you cannot recover the original URL from the hash; you need a DB lookup in both directions.
MD5 is a reasonable first answer in an interview, but always follow up with why you'd move away from it.
Option 2: Random Code + Uniqueness Check
import secrets
import string
BASE62_CHARS = string.ascii_letters + string.digits # 62 characters: a-z A-Z 0-9
def generate_code(length: int = 7) -> str:
return ''.join(secrets.choice(BASE62_CHARS) for _ in range(length))
def shorten_random(url: str, db, redis_client) -> str:
while True:
code = generate_code()
# Check Redis first (fast), then DB
if not redis_client.exists(f"url:{code}") and not db.exists(code):
db.insert(code, url)
redis_client.set(f"url:{code}", url, ex=3600)
return code62^7 = 3,521,614,606,208 — over 3.5 trillion combinations. Collision probability is astronomically low, but not zero. Under high concurrency (1,200 writes/sec), two workers could generate the same code simultaneously, both pass the existence check, and race to insert — requiring a unique constraint and retry logic.
This approach works but is inelegant. You can do better.
Option 3: Auto-Increment ID + Base62 Encoding (Best Approach)
This is the standard answer for senior-level system design. Generate a globally unique auto-incrementing integer ID, then convert it to Base62. No collisions possible. No DB check needed during generation.
Why Base62? The alphabet a-z (26) + A-Z (26) + 0-9 (10) = 62 URL-safe characters. No special characters, no encoding issues in URLs.
Why 7 characters? 62^7 = 3,521,614,606,208 unique codes. At 100M URLs per day, that is 96 years of capacity. 62^6 = 56 billion, still huge. 7 is the sweet spot.
BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def encode_base62(num: int) -> str:
"""Convert an integer to a Base62 string."""
if num == 0:
return BASE62[0]
result = []
while num:
result.append(BASE62[num % 62])
num //= 62
return ''.join(reversed(result))
def decode_base62(code: str) -> int:
"""Convert a Base62 string back to an integer."""
result = 0
for char in code:
result = result * 62 + BASE62.index(char)
return result
# Examples
print(encode_base62(1)) # → "b"
print(encode_base62(1000)) # → "qi"
print(encode_base62(1_000_000)) # → "4c92"
print(encode_base62(10_000_000)) # → "FXsk"
print(decode_base62("FXsk")) # → 10000000Sequence generation — two options:
Option A — PostgreSQL BIGSERIAL: the database auto-generates the ID on insert. Simple, but the ID is only known after the insert completes, adding a round-trip before you can compute the code.
Option B — Redis atomic counter (recommended for high write throughput):
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_next_id() -> int:
"""Atomically increment a global counter. Thread-safe across all servers."""
return r.incr("global:url_counter")
def shorten(original_url: str, user_id: str | None = None) -> str:
url_id = get_next_id()
code = encode_base62(url_id)
db.execute(
"""INSERT INTO urls (id, code, original_url, user_id, created_at)
VALUES (%s, %s, %s, %s, NOW())""",
(url_id, code, original_url, user_id)
)
# Pre-warm cache
r.setex(f"url:{code}", 3600, original_url)
return f"https://short.ly/{code}"Redis INCR is atomic. Even with 1,200 concurrent writes/sec across dozens of servers, each call gets a unique integer — no coordination needed beyond the Redis call.
Trade-off to surface in the interview: The sequential nature of IDs means codes are predictable (b, c, d, ...). Anyone who spots the pattern can enumerate all URLs. If privacy matters, XOR the ID with a random salt before encoding, or add a random suffix.
Database Schema Design
URL Table Schema
-- Primary URL storage table
CREATE TABLE urls (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(10) UNIQUE NOT NULL,
original_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ, -- NULL means never expires
is_active BOOLEAN DEFAULT TRUE,
custom_alias VARCHAR(50) UNIQUE, -- NULL if system-generated
click_count BIGINT DEFAULT 0 -- Approximate counter (see analytics)
);
-- Index for the hot path: redirect lookup by code
CREATE INDEX idx_urls_code ON urls(code);
-- Index for user dashboard: list a user's URLs
CREATE INDEX idx_urls_user_created ON urls(user_id, created_at DESC);
-- Index for expiry cleanup job
CREATE INDEX idx_urls_expires ON urls(expires_at)
WHERE expires_at IS NOT NULL AND is_active = TRUE;
-- Click analytics table (separate from hot path)
CREATE TABLE clicks (
id BIGSERIAL PRIMARY KEY,
url_id BIGINT NOT NULL REFERENCES urls(id),
clicked_at TIMESTAMPTZ DEFAULT NOW(),
ip_address INET,
user_agent TEXT,
referer TEXT,
country CHAR(2), -- ISO 3166-1 alpha-2
device_type VARCHAR(20) -- 'mobile', 'desktop', 'tablet', 'bot'
);
CREATE INDEX idx_clicks_url_time ON clicks(url_id, clicked_at DESC);SQL vs NoSQL: Which to Choose?
The redirect lookup is a simple key-value pattern: code → original_url. This is a perfect fit for both SQL and NoSQL. The right answer depends on what else the system needs.
Use PostgreSQL (SQL) when:
- You need ACID guarantees on URL creation (no duplicate codes, no phantom reads)
- You have complex analytics queries joining clicks to URLs
- Your team is comfortable with SQL and schema migrations
- Scale is under 50,000 reads/sec (easily handled with read replicas + connection pooling)
Use Cassandra or DynamoDB (NoSQL) when:
- You need horizontal write scaling beyond what a single primary can handle
- You need multi-region active-active writes (Cassandra's strong suit)
- The access pattern is truly key-value: you never join tables
- You can tolerate eventual consistency (a recently created URL might not be immediately readable in all regions)
Recommended approach for most interview answers: Start with PostgreSQL. It handles 1,200 writes/sec and 115,000 reads/sec (with caching and read replicas) without breaking a sweat. If the interviewer pushes for "what if you need to scale writes globally," introduce Cassandra or DynamoDB with a partition key of code and a sort key of created_at.
For DynamoDB specifically:
Table: urls
Partition key: code (String)
Attributes: original_url, user_id, created_at, expires_at, is_active
TTL attribute: expires_at ← DynamoDB handles expiry automaticallyDynamoDB's built-in TTL is particularly useful — it automatically deletes items past their expiry date without a separate cleanup job.
Caching Layer
Redirects are read-heavy and the access pattern follows a power-law distribution: a small fraction of URLs (viral links, marketing campaigns) generate the vast majority of traffic. This is ideal for caching.
What to Cache
Cache the mapping from short code to original URL in Redis:
CACHE_KEY_PREFIX = "url:"
CACHE_TTL_SECONDS = 3600 # 1 hour default
async def get_original_url(code: str, db, redis_client) -> str | None:
# 1. Check Redis first (microseconds)
cache_key = f"{CACHE_KEY_PREFIX}{code}"
cached = await redis_client.get(cache_key)
if cached:
return cached # Cache hit — done
# 2. Cache miss — check database (milliseconds)
row = await db.fetchone(
"SELECT original_url, expires_at, is_active FROM urls WHERE code = $1",
code
)
if not row or not row["is_active"]:
# Cache negative result briefly to prevent DB hammering
await redis_client.setex(f"{cache_key}:miss", 60, "1")
return None
if row["expires_at"] and row["expires_at"] < datetime.utcnow():
return None # Expired — do not cache
original_url = row["original_url"]
# Determine TTL: use URL expiry if set, otherwise default
ttl = CACHE_TTL_SECONDS
if row["expires_at"]:
remaining = int((row["expires_at"] - datetime.utcnow()).total_seconds())
ttl = min(ttl, remaining)
await redis_client.setex(cache_key, ttl, original_url)
return original_urlCache Key Design
- Key:
url:{code}→ original URL string - Value: the original URL (plain string, not JSON — keeps memory tight)
- TTL: 1 hour for most URLs; shorter if the URL has an expiry time
Eviction Policy
Set Redis maxmemory-policy to allkeys-lru. When Redis runs out of memory, it evicts the least recently used keys. Hot URLs stay in cache; cold URLs get evicted naturally.
Cache Hit Ratio Estimates
With 10B redirects/day and top 20% of URLs driving 80% of traffic:
- Top 20M URLs (of 100M total) × 500 bytes = 10 GB
- A single Redis node with 16 GB RAM easily fits the entire hot set
- Expected cache hit ratio: 85–95%
- DB queries absorbed: only 5–15% of 115,000 reads/sec = 5,750–17,250 queries/sec on the DB
That is well within what a single PostgreSQL primary with read replicas handles.
Cache warming for viral links: If a marketing campaign launches with a known short URL, pre-populate the cache before the campaign goes live. Prevents a cold-start stampede from hitting the DB.
Custom Aliases and Expiry
Custom Aliases
Users often want readable slugs: /sale2026, /oss-project, /resume. The schema already has the custom_alias column. The creation flow needs a uniqueness check:
async def shorten_with_alias(
original_url: str,
custom_alias: str | None,
user_id: str,
expires_at: datetime | None,
db,
redis_client
) -> dict:
if custom_alias:
# Validate: alphanumeric, hyphens, 3–50 chars
if not re.match(r'^[a-zA-Z0-9\-]{3,50}$', custom_alias):
raise ValueError("Invalid alias format")
# Check uniqueness — reserved words too
RESERVED = {"api", "admin", "login", "static", "health", "metrics"}
if custom_alias in RESERVED:
raise ValueError("Alias is reserved")
exists = await db.fetchone(
"SELECT 1 FROM urls WHERE code = $1 OR custom_alias = $1",
custom_alias
)
if exists:
raise ValueError("Alias already taken")
code = custom_alias
url_id = None # Custom aliases don't use the counter
else:
url_id = await get_next_id(redis_client)
code = encode_base62(url_id)
await db.execute(
"""INSERT INTO urls (id, code, original_url, user_id, created_at, expires_at, custom_alias)
VALUES ($1, $2, $3, $4, NOW(), $5, $6)""",
url_id, code, original_url, user_id, expires_at, custom_alias
)
# Cache immediately
ttl = 3600
if expires_at:
ttl = min(ttl, int((expires_at - datetime.utcnow()).total_seconds()))
await redis_client.setex(f"url:{code}", ttl, original_url)
return {"short_url": f"https://short.ly/{code}", "code": code}Rate-limit custom alias checks — a malicious user can probe which aliases exist by repeatedly calling the creation endpoint. Apply per-user rate limiting (e.g., 10 alias checks/minute).
Expiry and TTL-Based Cleanup
Two strategies for expired URLs:
Strategy 1: Lazy expiry — check expires_at on every redirect. If expired, return 410. Simple, but expired URLs occupy storage forever.
Strategy 2: Active cleanup job — run a background job (cron or Celery task) that soft-deletes expired rows:
-- Run every hour
UPDATE urls
SET is_active = FALSE
WHERE expires_at < NOW()
AND is_active = TRUE
AND expires_at IS NOT NULL;Use the partial index idx_urls_expires defined in the schema above — it only indexes rows with non-null expires_at where is_active = TRUE, keeping the index small.
For DynamoDB: set expires_at as the TTL attribute. DynamoDB handles deletion automatically within 24–48 hours of expiry.
Scaling the Redirect Service
The redirect service (hot path) handles 115,000 requests/sec. Here is how to scale it.
Read Replicas
The DB read load (after cache misses) hits the urls table with simple SELECT by code. Add PostgreSQL read replicas and route all redirect queries to replicas. Writes (URL creation) go to the primary only.
┌── Read Replica 1 ──┐
Redirect Service ────┼── Read Replica 2 ──┤── Primary (writes only)
└── Read Replica 3 ──┘CDN for Static Redirects
For URLs that will never change and never expire (permanent redirects), you can serve redirects from a CDN edge. The CDN caches the 301 response at the PoP closest to the user — no origin hit needed on repeat visits.
Caveat: CDN caching of redirects only works well with 301 (permanent). If you use 302 (temporary, for analytics), the CDN won't cache it and every request reaches your origin. Choose between CDN-cached 301 redirects and accurate per-request analytics — you cannot have both without client-side tracking.
Global Distribution with GeoDNS
A user in Tokyo hitting a server in Virginia adds 150–200ms round-trip latency just from network travel. That blows the 50ms budget immediately.
Solution: deploy redirect service in multiple regions with GeoDNS routing:
Tokyo user → GeoDNS → ap-northeast-1 (Tokyo) → Local Redis → Local DB replica
London user → GeoDNS → eu-west-1 (Ireland) → Local Redis → Local DB replica
New York user → GeoDNS → us-east-1 (Virginia) → Local Redis → Local DB replica
↑
All replicas sync from global primaryNew URL creation always writes to the global primary (single-region). Replication lag to replicas is typically < 100ms — acceptable for most use cases (a newly shortened URL might not be immediately redirectable from all regions for a brief window).
Horizontal Scaling of the Redirect Service
The redirect service is stateless (all state is in Redis and the DB). Scale horizontally behind a load balancer:
┌── Redirect Server 1 ──┐
User → LB ──── ├── Redirect Server 2 ──┤──── Redis Cluster ──── DB Read Replicas
└── Redirect Server N ──┘With each server handling ~5,000 req/sec (a conservative estimate for a simple Redis lookup + redirect), you need roughly 23 servers for 115,000 req/sec. Autoscale on CPU or request rate.
Analytics Tracking
Tracking every click at 115,000 req/sec is a write problem. You cannot write a clicks row on every redirect — that is 10 billion DB writes per day.
Async Writes via Message Queue
Decouple click recording from the redirect response:
from fastapi import FastAPI, BackgroundTasks, Request
from fastapi.responses import RedirectResponse
import aiokafka
app = FastAPI()
producer = aiokafka.AIOKafkaProducer(bootstrap_servers='kafka:9092')
@app.get("/{code}")
async def redirect_url(code: str, request: Request, background_tasks: BackgroundTasks):
original_url = await get_original_url(code, db, redis_client)
if not original_url:
raise HTTPException(status_code=404)
# Publish click event asynchronously — do not await
background_tasks.add_task(
publish_click_event,
code=code,
ip=request.client.host,
user_agent=request.headers.get("user-agent", ""),
referer=request.headers.get("referer", ""),
)
return RedirectResponse(url=original_url, status_code=302)
async def publish_click_event(code: str, ip: str, user_agent: str, referer: str):
event = {
"code": code,
"ts": time.time(),
"ip": ip,
"ua": user_agent,
"ref": referer,
}
await producer.send_and_wait("click-events", json.dumps(event).encode())A separate analytics consumer service reads from Kafka, batches events, and writes to the clicks table or a dedicated analytics store (ClickHouse works well for time-series click data).
In-Memory Click Buffer (Simpler Alternative)
For smaller scale or single-region deployments, buffer clicks in memory and flush periodically:
import asyncio
from collections import defaultdict
click_buffer = defaultdict(int) # code → count
buffer_lock = asyncio.Lock()
async def record_click_buffered(code: str):
async with buffer_lock:
click_buffer[code] += 1
async def flush_click_counts():
"""Flush buffered counts to DB every 10 seconds."""
while True:
await asyncio.sleep(10)
async with buffer_lock:
if not click_buffer:
continue
batch = dict(click_buffer)
click_buffer.clear()
# Batch update click counts
for code, count in batch.items():
await db.execute(
"UPDATE urls SET click_count = click_count + $1 WHERE code = $2",
count, code
)
# Start flush task on app startup
asyncio.create_task(flush_click_counts())Approximate Counting with HyperLogLog
For counting unique visitors (not total clicks), exact counting requires storing every IP address — expensive at scale. Redis HyperLogLog gives an approximate unique count with < 1% error using only 12 KB of memory per key:
async def record_unique_visitor(code: str, ip_address: str, redis_client):
"""Track unique visitor count using HyperLogLog (~12KB per URL)."""
hll_key = f"hll:visitors:{code}"
await redis_client.pfadd(hll_key, ip_address)
# Set 7-day TTL on the HLL key
await redis_client.expire(hll_key, 7 * 24 * 3600)
async def get_unique_visitor_count(code: str, redis_client) -> int:
"""Get approximate unique visitor count."""
return await redis_client.pfcount(f"hll:visitors:{code}")The Redirect Endpoint: Full Implementation
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import RedirectResponse
from datetime import datetime
import redis.asyncio as aioredis
app = FastAPI()
redis_client = aioredis.from_url("redis://localhost")
@app.get("/{code}")
async def redirect_url(
code: str,
request: Request,
background_tasks: BackgroundTasks
):
# Guard: reject obviously invalid codes early
if not code or len(code) > 50 or not code.replace("-", "").isalnum():
raise HTTPException(status_code=404)
# 1. Check Redis cache first
cache_key = f"url:{code}"
original_url = await redis_client.get(cache_key)
if not original_url:
# 2. Cache miss — check for negative cache (non-existent URL)
if await redis_client.exists(f"{cache_key}:miss"):
raise HTTPException(status_code=404)
# 3. Query DB
row = await db.fetchone(
"SELECT original_url, expires_at, is_active FROM urls WHERE code = $1",
code
)
if not row or not row["is_active"]:
await redis_client.setex(f"{cache_key}:miss", 60, "1")
raise HTTPException(status_code=404)
if row["expires_at"] and row["expires_at"] < datetime.utcnow():
raise HTTPException(status_code=410, detail="This URL has expired")
original_url = row["original_url"]
ttl = 3600
if row["expires_at"]:
ttl = min(ttl, int((row["expires_at"] - datetime.utcnow()).total_seconds()))
await redis_client.setex(cache_key, ttl, original_url)
# 4. Record click asynchronously (non-blocking)
background_tasks.add_task(record_click_buffered, code)
# 5. Return redirect
# 302 = temporary → browser re-requests each time → accurate analytics
# 301 = permanent → browser caches → faster repeat visits, but analytics miss repeats
return RedirectResponse(url=original_url, status_code=302)Complete Architecture Diagram
┌─────────────────────────────────┐
│ Global Primary DB │
│ PostgreSQL / DynamoDB │
│ (URL creation writes) │
└──────────────┬──────────────────-┘
│ replication
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ US-EAST Region │ │ EU-WEST Region │ │ AP-NE Region │
│ │ │ │ │ │
│ Redis Cluster │ │ Redis Cluster │ │ Redis Cluster │
│ DB Read Replica │ │ DB Read Replica │ │ DB Read Replica │
│ Redirect Svc ×N │ │ Redirect Svc ×N │ │ Redirect Svc ×N │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
└──────────────────────┼───────────────────────┘
▼
GeoDNS / CDN
▼
Client
│
┌──────────┴──────────┐
▼ ▼
URL Shortener Analytics Queue
Service (write) (Kafka)
┌──────────┐ │
│ Redis │ ▼
│ Counter │ Analytics Consumer
└──────────┘ │
▼
ClickHouse / Analytics DBFAQ
How does a URL shortener work?
A URL shortener accepts a long URL, generates a short unique code (typically 6–7 characters), stores the code-to-URL mapping in a database, and returns the short URL. When a user visits the short URL, the service looks up the code in a cache (Redis) or database, then issues an HTTP redirect (301 or 302) to the original long URL. The entire lookup and redirect typically takes under 10ms.
What algorithm does bit.ly use to generate short URLs?
bit.ly and most modern URL shorteners use a variant of Base62 encoding on a unique integer ID. The system maintains a globally unique counter (using an atomic database sequence or Redis INCR). Each new URL gets the next counter value, which is then encoded into a 6–7 character string using the Base62 alphabet (a-z, A-Z, 0-9). This guarantees uniqueness without any collision detection. Some implementations XOR the ID with a random salt to make codes less sequential and harder to enumerate.
Should a URL shortener use SQL or NoSQL?
Both work. Use PostgreSQL (SQL) if you need ACID guarantees, complex analytics queries, or your team is comfortable with relational schemas — it handles this scale easily with read replicas. Use Cassandra or DynamoDB (NoSQL) if you need multi-region active-active writes or want built-in TTL expiry (DynamoDB). The core access pattern — lookup by code — is simple key-value, which both handle equally well. Start with PostgreSQL; migrate to NoSQL if you hit write scaling limits.
How do you scale a URL shortener to billions of requests?
Four levers: (1) Redis caching — cache the code-to-URL mapping with a 1-hour TTL; viral URLs stay hot in memory and never hit the database; (2) Horizontal scaling — the redirect service is stateless, so add servers behind a load balancer; (3) Read replicas — route redirect DB queries to read replicas, reserve the primary for writes; (4) GeoDNS — deploy redirect services in multiple regions and route users to the nearest one, reducing network latency. With Redis absorbing 90%+ of reads, the database sees a fraction of total traffic.
How does a URL shortener track click analytics?
Never write to the analytics database synchronously on the redirect path — that adds latency and becomes a bottleneck at scale. Instead: (1) On each redirect, publish a click event to a message queue (Kafka, SQS) asynchronously as a background task; (2) A separate analytics consumer service reads from the queue, enriches the event (geo-lookup from IP, device detection from user-agent), and batch-writes to an analytics store (ClickHouse, BigQuery, or TimescaleDB); (3) For approximate unique visitor counts, use Redis HyperLogLog — it counts unique IPs with < 1% error using only 12 KB of memory per URL.
How do you handle custom aliases in a URL shortener?
Custom aliases replace the system-generated code with a user-chosen slug. On creation: validate format (alphanumeric + hyphens, 3–50 chars), check against a reserved words list (api, admin, health, etc.), and verify uniqueness against both the code and custom_alias columns with a DB uniqueness constraint as the final safety net. Cache the alias in Redis the same way as a system code (url:{alias} → original URL). Rate-limit alias availability checks to prevent enumeration attacks.
What is the difference between 301 and 302 redirects for URL shorteners?
301 Permanent Redirect: The browser caches the redirect. On the second visit, the browser goes directly to the destination without contacting your server. Faster for repeat users, but you lose click analytics for cached visits and you cannot update the redirect later (the cache sticks). Use 301 when performance matters more than analytics accuracy.
302 Temporary Redirect: The browser does not cache the redirect. Every visit goes through your server. Slower for repeat users, but you capture every click for analytics and can update the destination URL at any time. Most URL shorteners (bit.ly, TinyURL) use 302 for this reason. If you use CDN-level caching, be aware CDNs generally do not cache 302 responses, so every request reaches your origin.
Key Takeaways
- Auto-increment ID + Base62 encoding is the cleanest approach: no collisions, no DB checks, decodes back to ID
- 7-character Base62 = 62^7 = 3.5 trillion unique codes — enough for 96 years at 100M URLs/day
- Cache redirects in Redis with LRU eviction: hot URLs stay in RAM, cold URLs evict naturally; target 90%+ hit rate
- Use 302 for accurate analytics, 301 for browser-cached performance — you cannot have both without client-side JS tracking
- Record clicks asynchronously via Kafka or in-memory buffer — never block the redirect response on an analytics write
- Separate read and write paths: redirect service (high scale, stateless) vs creation service (low scale, writes to primary)
- Custom aliases need reserved-word filtering, rate limiting, and a DB uniqueness constraint as a final safety net
- Deploy redirect services in multiple regions with GeoDNS; new URL creation writes to a single global primary
URL shortener is a great interview exercise because it teaches hashing, caching, read/write separation, async processing, and global distribution — all in one compact problem.
Related reading: Database Indexing Explained · Redis Caching Explained · Caching Strategies Explained
Enjoyed this article?
Get weekly insights on backend architecture, system design, and Go programming.
Related Posts
Continue reading with these related posts
Caching Strategies Explained: Cache-Aside, Write-Through, Read-Through & Write-Behind (2026)
Cache-aside, read-through, write-through, write-behind — when to use each caching strategy, with Python and Node.js implementations, invalidation patterns, and cache hit-ratio metrics.
Consistent Hashing Explained: The Complete Distributed Systems Guide
Consistent hashing distributes data across servers so adding or removing a node moves only 1/N of keys. Learn virtual nodes, replication, production implementation, and how Cassandra and DynamoDB use it.
Redis Caching Explained: How It Works, Patterns, and Code Examples
Redis is an in-memory data store used as a cache to reduce database load and latency. Learn cache-aside, TTL, stampede prevention, eviction policies, and complete Python/Node.js code examples.