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.
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.
The API
| Request | Response | Notes |
|---|---|---|
POST /api/links | 201 {"code": "3fK9a2b"} | Takes an Idempotency-Key header |
GET /{code} | 302 Location: … | The hot path; 404 if unknown |
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.
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)}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.
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.
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)}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.