System design · Lesson 53 of 62 · about 75 min
System design fundamentals
A repeatable method for designing systems, back-of-the-envelope estimates, the building blocks (load balancers, caches, databases, queues, CDNs), consistency trade-offs, and reliability patterns with Go code.
On this page · 11 sections
The method#
Use the same steps for every design, in an interview or at work. Spending the first minutes on requirements is not a formality: it decides everything else.
| Step | Questions to answer | Output |
|---|---|---|
| 1. Requirements | What must it do (functional)? How fast, how available, how consistent (non-functional)? What is out of scope? | a short list of each |
| 2. Estimates | How many users, requests per second, bytes stored, bandwidth? Read-heavy or write-heavy? | a handful of numbers |
| 3. API | Which endpoints or messages, with which inputs and outputs? | 3 to 6 endpoints |
| 4. Data model | Which entities, which keys, which access patterns? | tables or documents with keys |
| 5. High-level design | Which boxes (clients, load balancer, services, stores, queues) and arrows? | a diagram |
| 6. Deep dives | The two or three hardest parts: hot keys, consistency, the core algorithm | detailed reasoning |
| 7. Bottlenecks and evolution | What breaks first at 10× and 100×? How do you observe it? | a list of next steps |
Back-of-the-envelope estimates#
You do not need precise numbers; you need the right order of magnitude to choose between "one server" and "a fleet".
Useful constants:
| Quantity | Value |
|---|---|
| seconds per day | 86,400 ≈ 10⁵ |
| requests/day → requests/second | divide by ~10⁵ |
| peak traffic | 2 to 5 × the average |
| 1 million × 1 KB | 1 GB |
| one modern server | thousands to tens of thousands of simple HTTP requests per second |
| one PostgreSQL primary | thousands of writes per second, more reads with replicas and caches |
Latency numbers (rounded, they shape every design):
| Operation | Time |
|---|---|
| read from L1 cache | 1 ns |
| read 1 MB sequentially from memory | ~10 µs |
| round trip within a datacenter | ~0.5 ms |
| read 1 MB from an SSD | ~1 ms |
| Redis GET over the network | ~0.5 to 1 ms |
| indexed database query | ~1 to 10 ms |
| round trip across a continent | ~50 to 150 ms |
The lesson of the table: memory is about a thousand times faster than the network, and the network inside a datacenter is about a hundred times faster than across the world. Caches and CDNs exist because of these ratios.
package main
import "fmt"
func human(bytes float64) string {
units := []string{"B", "KB", "MB", "GB", "TB", "PB"}
i := 0
for bytes >= 1000 i < len(units)-1 {
bytes /= 1000
i++
}
return fmt.Sprintf("%.1f %s", bytes, units[i])
}
func main() {
// A URL shortener: 100M new links a month, 100 reads per write, 500 bytes per record, kept 5 years.
const (
writesPerMonth = 100e6
readsPerWrite = 100
recordBytes = 500
years = 5
secondsPerDay = 86_400
)
writeQPS := writesPerMonth / 30 / secondsPerDay
readQPS := writeQPS * readsPerWrite
records := writesPerMonth * 12 * years
fmt.Printf("writes: %.0f/s average, %.0f/s at a 3x peak\n", writeQPS, writeQPS*3)
fmt.Printf("reads: %.0f/s average, %.0f/s at a 3x peak\n", readQPS, readQPS*3)
fmt.Printf("records after %d years: %.0f billion, storage %s\n", years, records/1e9, human(records*recordBytes))
fmt.Printf("read bandwidth: %s/s\n", human(readQPS*recordBytes))
// If 20% of links get 80% of reads, caching that hot 20% of one day's reads:
hotDaily := readQPS * secondsPerDay * 0.2
fmt.Printf("cache for the hot 20%% of a day's reads: about %s\n", human(hotDaily*recordBytes))
}writes: 39/s average, 116/s at a 3x peak
reads: 3858/s average, 11574/s at a 3x peak
records after 5 years: 6 billion, storage 3.0 TB
read bandwidth: 1.9 MB/s
cache for the hot 20% of a day's reads: about 33.3 GBFrom five lines of arithmetic: writes are modest (a single database handles them), reads are 100× higher (cache them), storage is a few terabytes over five years (fine, but plan for growth), and the hot set fits in the memory of one or two cache servers.
The building blocks#
Drawing diagram…
| Block | Job | Key decisions |
|---|---|---|
| load balancer | spread traffic, remove unhealthy servers | L4 (TCP) or L7 (HTTP); round robin, least connections, or hashing for stickiness |
| stateless app servers | run your code | keep session state out of memory (in a cache or token) so servers can come and go |
| cache | serve hot reads from memory | what to cache, TTL, eviction (LRU), invalidation, stampede protection |
| relational database | durable, consistent source of truth | schema, indexes, transactions, replicas for reads, sharding key when one primary is not enough |
| key-value / wide-column store | huge scale, simple access by key | partition key design, eventual consistency |
| message queue / log | decouple producers from consumers, absorb spikes, retry | at-least-once delivery, idempotent consumers, dead-letter queue, ordering per key |
| object storage | large binary files | presigned upload/download URLs, lifecycle rules |
| CDN | serve content near users | cache keys, TTLs, invalidation |
| search index | full-text and ranked search | how and when to index (usually from a queue) |
Load balancing algorithms
package main
import (
"fmt"
"hash/fnv"
)
type Backend struct {
Name string
Active int // open connections
Weight int
}
func roundRobin(bs []*Backend) func() *Backend {
i := -1
return func() *Backend {
i = (i + 1) % len(bs)
return bs[i]
}
}
func leastConnections(bs []*Backend) *Backend {
best := bs[0]
for _, b := range bs[1:] {
if b.Active < best.Active {
best = b
}
}
return best
}
// smoothWeighted is nginx's smooth weighted round robin: heavier backends get more turns, evenly spread.
func smoothWeighted(bs []*Backend) func() *Backend {
current := make([]int, len(bs))
total := 0
for _, b := range bs {
total += b.Weight
}
return func() *Backend {
best := 0
for i, b := range bs {
current[i] += b.Weight
if current[i] > current[best] {
best = i
}
}
current[best] -= total
return bs[best]
}
}
func hashPick(bs []*Backend, key string) *Backend {
h := fnv.New32a()
h.Write([]byte(key))
return bs[h.Sum32()%uint32(len(bs))]
}
func main() {
bs := []*Backend{{Name: "a", Active: 7, Weight: 5}, {Name: "b", Active: 2, Weight: 1}, {Name: "c", Active: 4, Weight: 1}}
rr := roundRobin(bs)
fmt.Print("round robin: ")
for range 6 {
fmt.Print(rr().Name, " ")
}
fmt.Println()
fmt.Println("least connections:", leastConnections(bs).Name)
sw := smoothWeighted(bs)
fmt.Print("smooth weighted 5:1:1: ")
for range 7 {
fmt.Print(sw().Name, " ")
}
fmt.Println()
fmt.Println("hash user-42 twice:", hashPick(bs, "user-42").Name, hashPick(bs, "user-42").Name)
}round robin: a b c a b c
least connections: b
smooth weighted 5:1:1: a a b a c a a
hash user-42 twice: a aHashing sends the same key to the same backend (useful for caches and sessions), but hash % n reshuffles almost every key when n changes. The distributed cache case study fixes that with consistent hashing.
Caching patterns#
| Pattern | Read path | Write path | Watch out for |
|---|---|---|---|
| cache-aside (most common) | app checks cache, on miss reads the DB and fills the cache | app writes the DB, then deletes the cache key | a brief stale window; stampedes on hot misses |
| read-through | the cache itself loads from the DB on a miss | same as cache-aside | needs a cache that supports loaders |
| write-through | reads hit the cache | write the cache and DB together | slower writes |
| write-behind | reads hit the cache | write the cache; flush to the DB later | data loss if the cache dies |
A cache stampede happens when a hot key expires and thousands of requests miss at once, all hitting the database. The fix is to let one request load the value while the others wait for its result. Go's extended library has this as golang.org/x/sync/singleflight; here it is in a few lines:
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
type call struct {
wg sync.WaitGroup
val string
}
// Group deduplicates concurrent loads of the same key (a tiny singleflight).
type Group struct {
mu sync.Mutex
calls map[string]*call
}
func (g *Group) Do(key string, load func() string) string {
g.mu.Lock()
if c, ok := g.calls[key]; ok {
g.mu.Unlock()
c.wg.Wait() // someone is already loading it: wait for their result
return c.val
}
c := &call{}
c.wg.Add(1)
g.calls[key] = c
g.mu.Unlock()
c.val = load()
c.wg.Done()
g.mu.Lock()
delete(g.calls, key)
g.mu.Unlock()
return c.val
}
type Cache struct {
mu sync.RWMutex
data map[string]string
sf Group
}
func (c *Cache) Get(key string, fromDB func(string) string) string {
c.mu.RLock()
v, ok := c.data[key]
c.mu.RUnlock()
if ok {
return v // hit
}
return c.sf.Do(key, func() string { // miss: only one goroutine loads
c.mu.RLock()
v, ok := c.data[key] // re-check: a load may have finished just before we got here
c.mu.RUnlock()
if ok {
return v
}
v = fromDB(key)
c.mu.Lock()
c.data[key] = v
c.mu.Unlock()
return v
})
}
func main() {
var dbQueries atomic.Int32
slowDB := func(k string) string {
dbQueries.Add(1)
time.Sleep(20 * time.Millisecond)
return "value-of-" + k
}
c := &Cache{data: map[string]string{}, sf: Group{calls: map[string]*call{}}}
var wg sync.WaitGroup
for range 100 { // 100 simultaneous requests for a cold, hot key
wg.Add(1)
go func() {
defer wg.Done()
c.Get("homepage", slowDB)
}()
}
wg.Wait()
fmt.Println("requests: 100, database queries:", dbQueries.Load())
fmt.Println(c.Get("homepage", slowDB), "| queries after a warm hit:", dbQueries.Load())
}requests: 100, database queries: 1
value-of-homepage | queries after a warm hit: 1Data: replication, partitioning and consistency#
- Replication keeps copies of the data on several machines. A leader (primary) takes writes; followers (replicas) copy them and serve reads. Reads from a follower may be slightly stale (replication lag).
- Partitioning (sharding) splits data across machines by a partition key, such as
user_id. A good key spreads load evenly and keeps data that is queried together on the same shard. A bad key (a timestamp, a celebrity's ID) creates hot partitions. - CAP: during a network partition, a system must choose between consistency (refuse some requests) and availability (answer, possibly with stale data). PACELC adds: even without partitions, there is a trade-off between latency and consistency.
- Quorums: with N replicas, writing to W and reading from R, any read overlaps the latest write when
R + W > N(for example N=3, W=2, R=2).
| Consistency level | Guarantee | Example need |
|---|---|---|
| strong (linearizable) | every read sees the latest write | bank balances, inventory counts, unique usernames |
| read-your-writes | you always see your own changes | editing your profile |
| eventual | replicas converge if writes stop | like counts, view counts, feeds |
Reliability patterns#
Everything fails eventually: networks drop packets, servers restart, dependencies slow down. Four patterns keep one failure from spreading:
- Timeouts on every network call. A call without a timeout can hang forever and tie up a goroutine, a connection and a user.
- Retries with exponential backoff and jitter for transient errors. Double the wait each attempt, cap it, and randomize it so clients do not retry in lockstep.
- Circuit breakers: after repeated failures, stop calling a broken dependency for a while and fail fast, then let a trial request through to see if it recovered.
- Idempotency: make retried requests safe. The client sends a unique
Idempotency-Key; the server stores the result for that key and returns it again on a repeat instead of doing the work twice.
package main
import (
"errors"
"fmt"
"math/rand/v2"
"time"
)
// backoff returns the wait before attempt n (0-based): exponential, capped, with "full jitter".
func backoff(n int, base, maxWait time.Duration, r *rand.Rand) time.Duration {
d := min(base<<n, maxWait)
return time.Duration(r.Int64N(int64(d) + 1)) // anywhere from 0 to d
}
type State int
const (
Closed State = iota // normal: requests flow
Open // failing: reject immediately
HalfOpen // probing: let one request test the dependency
)
func (s State) String() string { return [...]string{"closed", "open", "half-open"}[s] }
var ErrOpen = errors.New("circuit open: failing fast")
type Breaker struct {
state State
failures int
threshold int
cooldown time.Duration
openedAt time.Time
}
func (b *Breaker) Call(now time.Time, fn func() error) error {
if b.state == Open {
if now.Sub(b.openedAt) < b.cooldown {
return ErrOpen
}
b.state = HalfOpen // cooldown over: allow a trial request
}
if err := fn(); err != nil {
b.failures++
if b.state == HalfOpen b.failures >= b.threshold {
b.state, b.openedAt = Open, now
}
return err
}
b.state, b.failures = Closed, 0
return nil
}
// Idempotency: remember results by key so a retried request is not processed twice.
type Payments struct {
results map[string]string
charged int
}
func (p *Payments) Charge(idemKey string, amount int) string {
if r, ok := p.results[idemKey]; ok {
return r + " (replayed, not charged again)"
}
p.charged += amount
r := fmt.Sprintf("charged %d", amount)
p.results[idemKey] = r
return r
}
func main() {
r := rand.New(rand.NewPCG(1, 1))
fmt.Print("backoff caps: ")
for n := range 7 {
fmt.Print(min(100*time.Millisecond<<n, 3*time.Second), " ")
}
fmt.Println()
fmt.Println("one jittered wait for attempt 3 is at most 800ms:", backoff(3, 100*time.Millisecond, 3*time.Second, r) <= 800*time.Millisecond)
b := &Breaker{threshold: 3, cooldown: 10 * time.Second}
t0 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
failing := func() error { return errors.New("upstream 503") }
for i := range 5 {
err := b.Call(t0.Add(time.Duration(i)*time.Second), failing)
fmt.Printf("t=%ds state=%-9s err=%v\n", i, b.state, err)
}
err := b.Call(t0.Add(15*time.Second), func() error { return nil }) // after the cooldown, a trial succeeds
fmt.Printf("t=15s state=%-9s err=%v\n", b.state, err)
p := &Payments{results: map[string]string{}}
fmt.Println(p.Charge("order-77", 500))
fmt.Println(p.Charge("order-77", 500)) // the client retried after a timeout
fmt.Println("total charged:", p.charged)
}backoff caps: 100ms 200ms 400ms 800ms 1.6s 3s 3s
one jittered wait for attempt 3 is at most 800ms: true
t=0s state=closed err=upstream 503
t=1s state=closed err=upstream 503
t=2s state=open err=upstream 503
t=3s state=open err=circuit open: failing fast
t=4s state=open err=circuit open: failing fast
t=15s state=closed err=<nil>
charged 500
charged 500 (replayed, not charged again)
total charged: 500Observability#
You cannot fix what you cannot see. Instrument from day one:
- Logs: structured, with request IDs (
log/slogin the standard library). - Metrics: request rate, error rate and latency percentiles (p50, p95, p99) per endpoint, plus saturation (CPU, memory, queue depth). Watching the p99 matters: one slow call in a page with 50 calls makes most page loads slow.
- Traces: one request's path through every service (OpenTelemetry).
- SLOs: a target like "99.9% of requests under 300 ms over 30 days" turns "is it fast enough?" into a number you can alert on.
How the guided builds work#
Every case study ends with Build it: numbered steps that grow a small but real application, from an empty folder to a deployable service. All builds share the same conventions so you can focus on the design:
| Piece | Choice | Why |
|---|---|---|
| language | Go 1.24+, standard library first | net/http routing, log/slog, embed, testing are enough to start |
| layout | main.go wiring; internal/<domain> for logic; web/ for the frontend | logic stays testable without HTTP |
| storage | an interface with an in-memory implementation first | swap in PostgreSQL or Redis later without touching handlers |
| frontend | one HTML page with a little JavaScript, embedded into the binary with //go:embed | one binary to deploy; replace with React or Svelte if you like |
| tests | table tests for the domain, httptest for the API | go test ./... must pass at every step |
| deploy | a multi-stage Dockerfile | runs on Fly.io, Railway, Render, Google Cloud Run or any VPS |
Every code block in a build is checked by this site's test suite: the files are assembled into a project, then formatted, vetted, built and tested, so what you type works.
The shared Dockerfile used by every project:
# Build stage: compile a static binary.
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app .
# Run stage: a tiny image with just the binary.
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]docker build -t myapp .
docker run -p 8080:8080 myapp
# then, for example: fly launch (or push the image to Cloud Run, Railway, Render)Summary#
- Follow the method: requirements, estimates, API, data model, high-level design, deep dives, bottlenecks.
- Estimate orders of magnitude: divide daily counts by ~10⁵ for per-second rates and plan for peaks.
- Combine stateless servers, caches, databases, queues, object storage and CDNs; each block has known trade-offs.
- Choose consistency per feature; quorums give strong reads when
R + W > N. - Timeouts, backoff with jitter, circuit breakers and idempotency keep failures contained.
- Every case study ends with a verified, step-by-step full-stack build in Go.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.1 million daily active users each make 20 requests a day. Roughly what is the average request rate?
Q2.With cache-aside, what happens on a cache miss?
Q3.Why add random jitter to exponential backoff?
Q4.With N replicas, reads from R and writes to W replicas, when is a read guaranteed to see the latest write?
Q5.What makes a retried request safe for operations like 'charge this card'?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
- Estimate a photo-sharing appbuild
10M DAU, 2 uploads/day at 500 KB, 50 views/day. Estimate write QPS, read QPS, storage per year, and bandwidth. Write the numbers into a Go program like the one in this lesson.
- Add a circuit breaker to an HTTP clientbuild
Wrap
http.Client.Dowith the breaker from this lesson and test it with anhttptest.Serverthat fails the first 5 requests.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.