Skip to content
IR
02system design

Designing a URL Shortener

A small service with a demanding read path, built end to end: how to mint short codes that never collide, and how to keep redirects fast when reads outnumber writes a hundred to one.

Published (15 September 2026)9 min readGoArchitectureCaching

Scope and estimates

A user submits a long URL and gets back a short one. Anyone who opens the short link is redirected. Links never change once created. That last rule is what makes everything else simple: an immutable value can be cached anywhere, forever.

~40/sNew links, from 100M a month
~4,000/sRedirects at a 100:1 read ratio
~600 GBPer year at 500 bytes a link
3.5TCodes in 7 base62 characters
Figure 1 · Working assumptions, not measurements. Peaks run about five times the average.

The API

RequestResponseNotes
POST /api/links201 {"code": "3fK9a2b"}Takes an Idempotency-Key header
GET /{code}302 Location: …The hot path; 404 if unknown
Table 1 · Two endpoints. Only the second one has to be fast.

Minting short codes

There are three common ways to turn a long URL into seven characters. Only one of them never needs to ask the database "is this taken?".

Hash and truncate

Same URL, same code. Truncation collides, so every write needs a check-and-retry.

Random

Unguessable, but collisions grow as the space fills. Still a check on every write.

Counter + base62

Every number is unique by construction. No checks, no retries. This is the one we build.

Ranges and base62

A single global counter would be a bottleneck, so each API server leases a block of a million IDs and hands them out from memory. The shared counter is touched once per million links.

ID range leasingPNG or SVG · 1600 × 900 · light background
Figure 2 · A shared counter at 3,000,000. Server A holds 1,000,000–1,999,999; server B holds 2,000,000–2,999,999. If a server dies, the rest of its range is skipped. Gaps are fine; duplicates are not.
shortener/code.go
package shortener
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
type Allocator struct {	mu    sync.Mutex	next  uint64	limit uint64	store RangeStore}
func (a *Allocator) Next() (uint64, error) {	a.mu.Lock()	defer a.mu.Unlock()
	if a.next >= a.limit {		start, err := a.store.LeaseRange(1_000_000)		if err != nil {			return 0, err		}		a.next, a.limit = start, start+1_000_000	}
	id := a.next	a.next++	return id, nil}
func Encode(id uint64) string {	if id == 0 {		return string(alphabet[0])	}	buf := make([]byte, 0, 11)	for id > 0 {		buf = append(buf, alphabet[id%62])		id /= 62	}	slices.Reverse(buf)	return string(buf)}
Listing 1 · Leasing ranges and encoding IDs. Seven characters cover IDs up to about 3.5 trillion.

Architecture

Writes and reads take separate paths. Writes are rare and can afford a database round trip. Reads must usually end before they reach one.

System architecturePNG or SVG · 1600 × 900 · light background
Figure 3 · Write path (~40/s): client → API service → allocator → link store. Read path (~4,000/s): browser → edge cache → redirect service → hot-link cache → link store. Most redirects stop at the first cache they meet.

The redirect hot path

Cache first, store second, and write back on a miss. Because links are immutable there is no invalidation to get wrong; the TTL only exists to let cold links fall out of memory.

shortener/redirect.go
func (s *Service) Redirect(w http.ResponseWriter, r *http.Request) {	code := strings.TrimPrefix(r.URL.Path, "/")
	if target, ok := s.cache.Get(code); ok {		http.Redirect(w, r, target, http.StatusFound)		return	}
	target, err := s.store.Lookup(r.Context(), code)	if err != nil {		http.NotFound(w, r)		return	}	s.cache.Set(code, target, 24*time.Hour)
	http.Redirect(w, r, target, http.StatusFound)}
Listing 2 · The highlighted lines are the miss path: one store read, then the cache is filled for everyone after.

The allocator in Listing 1 guarantees the code is unique; the cache in the listing above is what makes it cheap to serve.

301 or 302?

301 Moved Permanently

Browsers cache it and skip you next time. Cheapest to serve, but you stop seeing repeat clicks.

302 Found

Every click comes back through you. Costs a request, keeps analytics and lets you disable abusive links.

We use 302 and let the edge cache absorb the cost. The browser still asks; it just never reaches the origin.