Go Guide

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
  1. 01The method
  2. 02Back-of-the-envelope estimates
  3. 03The building blocks
  4. 04Caching patterns
  5. 05Data: replication, partitioning and consistency
  6. 06Reliability patterns
  7. 07Observability
  8. 08How the guided builds work
  9. 09Summary
  10. 10Check your understanding
  11. 11Practice in Go

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.

StepQuestions to answerOutput
1. RequirementsWhat must it do (functional)? How fast, how available, how consistent (non-functional)? What is out of scope?a short list of each
2. EstimatesHow many users, requests per second, bytes stored, bandwidth? Read-heavy or write-heavy?a handful of numbers
3. APIWhich endpoints or messages, with which inputs and outputs?3 to 6 endpoints
4. Data modelWhich entities, which keys, which access patterns?tables or documents with keys
5. High-level designWhich boxes (clients, load balancer, services, stores, queues) and arrows?a diagram
6. Deep divesThe two or three hardest parts: hot keys, consistency, the core algorithmdetailed reasoning
7. Bottlenecks and evolutionWhat 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:

QuantityValue
seconds per day86,400 ≈ 10⁵
requests/day → requests/seconddivide by ~10⁵
peak traffic2 to 5 × the average
1 million × 1 KB1 GB
one modern serverthousands to tens of thousands of simple HTTP requests per second
one PostgreSQL primarythousands of writes per second, more reads with replicas and caches

Latency numbers (rounded, they shape every design):

OperationTime
read from L1 cache1 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.

estimate.go
Go
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))
}
Output
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 GB

From 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…
BlockJobKey decisions
load balancerspread traffic, remove unhealthy serversL4 (TCP) or L7 (HTTP); round robin, least connections, or hashing for stickiness
stateless app serversrun your codekeep session state out of memory (in a cache or token) so servers can come and go
cacheserve hot reads from memorywhat to cache, TTL, eviction (LRU), invalidation, stampede protection
relational databasedurable, consistent source of truthschema, indexes, transactions, replicas for reads, sharding key when one primary is not enough
key-value / wide-column storehuge scale, simple access by keypartition key design, eventual consistency
message queue / logdecouple producers from consumers, absorb spikes, retryat-least-once delivery, idempotent consumers, dead-letter queue, ordering per key
object storagelarge binary filespresigned upload/download URLs, lifecycle rules
CDNserve content near userscache keys, TTLs, invalidation
search indexfull-text and ranked searchhow and when to index (usually from a queue)

Load balancing algorithms

balancer.go
Go
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)
}
Output
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 a

Hashing 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#

PatternRead pathWrite pathWatch out for
cache-aside (most common)app checks cache, on miss reads the DB and fills the cacheapp writes the DB, then deletes the cache keya brief stale window; stampedes on hot misses
read-throughthe cache itself loads from the DB on a misssame as cache-asideneeds a cache that supports loaders
write-throughreads hit the cachewrite the cache and DB togetherslower writes
write-behindreads hit the cachewrite the cache; flush to the DB laterdata 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:

cacheaside.go
Go
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())
}
Output
requests: 100, database queries: 1
value-of-homepage | queries after a warm hit: 1

Data: 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 levelGuaranteeExample need
strong (linearizable)every read sees the latest writebank balances, inventory counts, unique usernames
read-your-writesyou always see your own changesediting your profile
eventualreplicas converge if writes stoplike counts, view counts, feeds

Reliability patterns#

Everything fails eventually: networks drop packets, servers restart, dependencies slow down. Four patterns keep one failure from spreading:

  1. Timeouts on every network call. A call without a timeout can hang forever and tie up a goroutine, a connection and a user.
  2. 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.
  3. 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.
  4. 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.
reliability.go
Go
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)
}
Output
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: 500

Observability#

You cannot fix what you cannot see. Instrument from day one:

  • Logs: structured, with request IDs (log/slog in 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:

PieceChoiceWhy
languageGo 1.24+, standard library firstnet/http routing, log/slog, embed, testing are enough to start
layoutmain.go wiring; internal/<domain> for logic; web/ for the frontendlogic stays testable without HTTP
storagean interface with an in-memory implementation firstswap in PostgreSQL or Redis later without touching handlers
frontendone HTML page with a little JavaScript, embedded into the binary with //go:embedone binary to deploy; replace with React or Svelte if you like
teststable tests for the domain, httptest for the APIgo test ./... must pass at every step
deploya multi-stage Dockerfileruns 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:

Dockerfile
# 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"]
Shell
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.

  1. Q1.1 million daily active users each make 20 requests a day. Roughly what is the average request rate?

  2. Q2.With cache-aside, what happens on a cache miss?

  3. Q3.Why add random jitter to exponential backoff?

  4. Q4.With N replicas, reads from R and writes to W replicas, when is a read guaranteed to see the latest write?

  5. 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.

Finished this lesson?

Mark it done to track your progress. It is saved in this browser only.