Free lesson · System Design Interviews
Step 4: High-Level Architecture
This is the step most candidates think of as "the design" - but by now, you should see that it's actually step 4 of 8. The requirements told us what to build. The estimation told us how big. The API told us what operations to support. Now we draw the architecture that brings all of that together.
The high-level architecture should take about 5 minutes to draw and explain. Start simple - 6 to 8 boxes with clear data flows. You'll add detail in the deep dives. The goal here is to show the interviewer a coherent system where every component has a clear reason to exist.
The Architecture
flowchart TB
CLIENT["Client<br/>(Browser / Mobile / API)"] --> LB["Load Balancer<br/>(ALB)"]
LB -->|"POST /api/v1/shorten"| WRITE_API["Write API Servers<br/>(URL creation)"]
LB -->|"GET /:shortCode"| READ_API["Read API Servers<br/>(URL redirection)"]
READ_API -->|"1. Check cache first"| CACHE[("Redis Cache<br/>Hot URL mappings<br/>~30 GB")]
CACHE -->|"Cache hit → redirect"| READ_API
CACHE -.->|"2. Cache miss → query DB"| DB
WRITE_API -->|"3. Generate short code"| ID_GEN["Short Code<br/>Generator"]
WRITE_API -->|"4. Store mapping"| DB[("PostgreSQL<br/>Primary + 2 Replicas<br/>URL mappings")]
WRITE_API -->|"5. Populate cache"| CACHE
READ_API -->|"6. Log click event<br/>(async)"| KAFKA["Kafka"]
KAFKA --> ANALYTICS["Analytics Worker<br/>→ Update click counts"]
ANALYTICS --> DB
High-level architecture for the URL shortener. Clients connect through an Application Load Balancer. The load balancer routes creation requests to Write API servers and redirect requests to Read API servers. For redirects, the Read API checks the Redis cache first. On a cache hit, it redirects immediately. On a cache miss, it queries the PostgreSQL database, caches the result, and redirects. For URL creation, the Write API uses a Short Code Generator to create a unique 7-character code, stores the mapping in PostgreSQL, and populates the Redis cache. Click events are logged asynchronously through Kafka to an Analytics Worker that updates click counts in the database. This separation keeps the redirect path fast and the analytics path non-blocking.
Let me walk through each component and explain why it exists.
Component-by-Component Breakdown
Load Balancer (ALB)
Why it exists: Distributes traffic across multiple API server instances. Provides health checking - if a server crashes, the LB stops routing to it. Also terminates SSL, so the API servers don't need to handle TLS encryption.
Choice: AWS Application Load Balancer. It's managed (no operational overhead), inherently multi-AZ (no SPOF), and supports path-based routing - we can route /api/v1/* to write servers and /:shortCode to read servers if we want to scale them independently.
Separate Read and Write API Servers
Why separate? Because they have fundamentally different scaling requirements and performance characteristics.
| Characteristic | Write API | Read API |
|---|---|---|
| QPS | ~1,000 avg | ~100,000 avg |
| Latency target | <200ms | <50ms |
| Scaling need | 2-3 instances sufficient | 10-20 instances needed |
| State requirements | Needs code generation logic | Stateless cache lookup |
| Failure impact | New URLs can't be created (tolerable briefly) | All short links break (critical) |
flowchart LR
subgraph WRITE["Write Path (1,000 QPS)"]
W1["Write Server 1"]
W2["Write Server 2"]
W3["Write Server 3"]
end
subgraph READ["Read Path (100,000 QPS)"]
R1["Read Server 1"]
R2["Read Server 2"]
R3["..."]
R10["Read Server 10"]
end
NOTE["Scale independently:<br/>3 write servers<br/>10+ read servers"]
Separating read and write API servers allows independent scaling. The write path needs only 2-3 servers to handle 1,000 QPS. The read path needs 10 or more servers to handle 100,000 QPS. Each can be scaled, deployed, and optimized independently. If the read path needs more capacity during a traffic spike, add more read servers without touching the write infrastructure.
Tip: In an interview, you can simplify this. If the interviewer doesn't ask about scaling, a single pool of API servers that handles both reads and writes is fine for the high-level design. Mention the separate-pools approach during the scaling deep dive: "When we need to scale, I'd separate the read and write servers so they can scale independently - the read path needs 10x more instances."
Redis Cache
Why it exists: The database can handle ~10,000 reads per second. We need ~100,000. The cache bridges that gap. Redis serves reads in <1ms - compared to 5-10ms for a database query.
What's cached: The URL mapping: shortCode → longURL. Each entry is ~300 bytes.
Cache strategy: Write-through. When a new URL is created, it's written to both the database and the cache simultaneously. This ensures the cache is always warm for newly created URLs (which get the most traffic).
# Write path: write-through caching
def create_short_url(long_url, custom_code=None):
short_code = custom_code or generate_short_code()
# Write to database (source of truth)
db.execute(
"INSERT INTO url_mappings (short_code, long_url, created_at, expires_at) "
"VALUES (%s, %s, NOW(), NOW() + INTERVAL '5 years')",
short_code, long_url
)
# Write to cache (for fast reads)
redis.setex(
f"url:{short_code}",
ttl=86400, # Cache for 24 hours
value=long_url
)
return short_code
# Read path: cache-first
def redirect(short_code):
# 1. Check cache first
long_url = redis.get(f"url:{short_code}")
if long_url:
# Cache hit - redirect immediately
log_click_async(short_code) # Non-blocking
return redirect_302(long_url)
# 2. Cache miss - check database
long_url = db.query(
"SELECT long_url FROM url_mappings "
"WHERE short_code = %s AND expires_at > NOW()",
short_code
)
if long_url:
# Populate cache for next time
redis.setex(f"url:{short_code}", ttl=86400, value=long_url)
log_click_async(short_code)
return redirect_302(long_url)
return not_found_404()
PostgreSQL Database
Why PostgreSQL: Relational data (URL mappings) with ACID guarantees. Supports UNIQUE constraints for preventing duplicate short codes. Handles our write throughput (1,000 QPS) comfortably. Mature replication for read replicas.
Architecture: Primary + 2 read replicas across availability zones.
- Primary: Handles all writes (URL creation) and cache-miss reads
- Replicas: Handle read-heavy queries (stats endpoint, cache-miss overflow)
Short Code Generator
Why a separate component? Generating unique, non-colliding 7-character codes is the most interesting design challenge in a URL shortener. It deserves its own deep dive (Topic 5). For now, at the high-level:
The generator produces unique 7-character Base62 codes. Two main approaches:
- Hash the long URL + salt and take the first 7 characters of the hash. Check for collisions against the database.
- Use an auto-incrementing counter and convert it to Base62. No collision checking needed.
We'll deep-dive into both approaches in the next topic.
Kafka + Analytics Worker
Why async analytics? The redirect path must be fast (<50ms). Incrementing a database counter synchronously on every redirect adds 5-10ms of latency. Instead, we publish click events to Kafka asynchronously (fire-and-forget from the redirect server's perspective), and a background worker processes them in batches.
sequenceDiagram
participant U as User
participant R as Read Server
participant C as Redis Cache
participant K as Kafka
participant W as Analytics Worker
participant DB as Database
U->>R: GET /a3Xb9k
R->>C: Get url:a3Xb9k
C-->>R: https://example.com/long
R->>K: Publish click event (async, non-blocking)
R-->>U: 302 Redirect to example.com/long
Note over U: User sees destination page<br/>(total latency: ~5ms)
Note over K,W: Async processing (seconds later)
K->>W: Batch of click events
W->>DB: UPDATE click_count +1 WHERE code = 'a3Xb9k'
Async analytics flow. The user requests a short URL. The read server checks Redis cache and finds the long URL. It publishes a click event to Kafka asynchronously - this is non-blocking and doesn't add latency to the redirect. The user receives the 302 redirect in about 5 milliseconds. Seconds later, the analytics worker consumes the click event from Kafka and updates the click count in the database. The user never waits for analytics to complete.
Interview: What to say when presenting the architecture: "Let me walk through the two main data flows. For URL creation: the client sends a POST to the write API, which generates a unique 7-character code, stores the mapping in PostgreSQL, and populates the Redis cache. For redirects: the client hits the read API, which checks Redis first - if it's a cache hit, we redirect immediately in under 5 milliseconds. If it's a cache miss, we query the database, cache the result for next time, and redirect. Click analytics are logged asynchronously to Kafka so they don't slow down the redirect path. The read and write paths scale independently - I need 10x more read servers than write servers given the 100:1 ratio."
The Data Flow Summary
| Operation | Path | Latency |
|---|---|---|
| Create URL | Client → LB → Write API → Code Gen → DB + Cache | ~50-100ms |
| Redirect (cache hit) | Client → LB → Read API → Cache → 302 | ~5-10ms |
| Redirect (cache miss) | Client → LB → Read API → Cache (miss) → DB → Cache (populate) → 302 | ~15-30ms |
| Click tracking | Read API → Kafka → Worker → DB | Async (seconds) |
| Get stats | Client → LB → Read API → DB (or cache) | ~10-50ms |
Why This Architecture Works
Let me connect each design decision back to our requirements:
| Requirement | How the Architecture Meets It |
|---|---|
| 100K read QPS | Redis cache serves 99%+ of redirects at <1ms per read |
| <50ms redirect latency | Cache-first reads. No synchronous DB or analytics on the redirect path |
| 1K write QPS | Single PostgreSQL primary handles this easily |
| Zero data loss | PostgreSQL with replication. Cache is a performance layer, not the source of truth |
| 99.99% availability | Multi-AZ deployment. ALB, Redis Sentinel, PostgreSQL replicas - no SPOF |
| URL expiration | expires_at column in DB. Cache TTL. Background cleanup job |
| Custom short URLs | Uniqueness check against DB before insertion |
Key Takeaways
-
The architecture is cache-first for reads, write-through for writes. 99%+ of redirect traffic is served from Redis cache at <1ms. Cache misses fall through to PostgreSQL. New URL mappings are written to both the database and cache simultaneously.
-
Read and write paths are separated for independent scaling. The write path needs 2-3 servers. The read path needs 10+. Separating them lets you scale each independently based on their different traffic patterns.
-
Analytics are async to protect redirect latency. Click events are published to Kafka without blocking the redirect response. The user gets their redirect in <10ms regardless of analytics processing time.
-
PostgreSQL is the source of truth, Redis is the performance layer. If Redis goes down, the system still works (slower, hitting the database directly). If PostgreSQL goes down, you've lost your data. This hierarchy is critical to understand.
-
Every component has a clear reason to exist. Load balancer for distribution and health checking. Separate API pools for independent scaling. Redis for read performance. PostgreSQL for durability. Kafka for async processing. No component is there "just because."
-
The high-level design has 7 component types - clean and readable. Client, LB, Write API, Read API, Cache, Database, Async Pipeline. Enough to show the full architecture without cluttering the diagram.
Exercise: Exercise 1: Draw this architecture on a whiteboard (or Excalidraw) in under 5 minutes, talking out loud as you draw. Time yourself. Can you explain every component and data flow within the time limit?
Exercise 2: The interviewer asks: "What happens if Redis goes down entirely?" Trace the redirect flow without the cache. What's the impact on latency and throughput? How many read QPS can the database handle? At what point does the database become overwhelmed? How do you recover - do you warm the cache proactively or let it fill on demand?
Exercise 3: The interviewer asks: "What if this needs to work globally - users in Europe, Asia, and the Americas?" How do you modify the architecture for multi-region? Where do you add CDN? Which components are replicated per region? How do you handle a URL created in the US being accessed in Asia - does the mapping need to exist in Asia's database?