# Building a URL Shortener in Go: From TCP Sockets to a Working Service

- URL: https://karandeepsingh.ca/posts/url-shortener-from-scratch-go/
- Author: Karandeep Singh
- Published: 2023-08-28
- Updated: 2026-08-10
- Series: Linux Commands to Go
- Tags: golang, linux
- Summary: A step-by-step build of a URL shortener in Go using only the standard library — raw TCP, HTTP parsing, hash-based short codes, file persistence, 302 redirects with click analytics, and rate limiting. Each step introduces a real bug first, then fixes it.

A URL shortener takes a long URL and hands back a short one. Visit the short URL and the
server redirects you to the original. That is the entire product — and it is a genuinely
good thing to build, because underneath that one sentence sits TCP, HTTP parsing,
hashing and collisions, persistence, redirect semantics, and abuse protection.

We will build it in Go, standard library only. No frameworks, no database, no external
services. Each step starts with a Linux command so you can watch the concept happen in
your terminal, then we write the Go equivalent. And at nearly every step we will hit a
real bug first, look at what broke, and then fix it.

You need a Linux or macOS terminal and Go installed. Nothing else.

## What we are building

Three endpoints, and this is how a request moves through them:

```mermaid
flowchart TD
    A["POST /shorten"] --> B{"Rate limit OK?"}
    B -->|no| C["429 Too Many Requests"]
    B -->|yes| D{"Valid http(s) URL?"}
    D -->|no| E["400 Bad Request"]
    D -->|yes| F["sha256 → 7-char code"]
    F --> G{"Code taken by<br/>a different URL?"}
    G -->|yes| H["extend code<br/>until unique"]
    G -->|no| I["write to map + urls.txt"]
    H --> I
    I --> J["201 + short URL"]

    K["GET /{code}"] --> L{"Code exists?"}
    L -->|no| M["404 Not Found"]
    L -->|yes| N["record click"]
    N --> O["302 → original URL"]

    P["GET /stats/{code}"] --> Q["click count,<br/>referrers, recent hits"]
```

To keep this readable, each step below shows **only the code that changes**. The complete
program appears once at the end.

## Step 1: HTTP is just text over TCP

Before any Go, watch what a browser actually sends. Open two terminals. In the first:

```bash
nc -l 8080
```

In the second:

```bash
curl -v http://localhost:8080/test
```

The first terminal prints the raw request:

```text
GET /test HTTP/1.1
Host: localhost:8080
User-Agent: curl/7.81.0
Accept: */*

```

That is all HTTP is: a request line (method, path, version), headers, a blank line, then
an optional body. Type a response back into the netcat terminal and press Enter:

```text
HTTP/1.1 200 OK
Content-Type: text/plain

Hello from netcat
```

Press Ctrl+C. Your response appears in the curl terminal. No magic anywhere.

### The Go version

```go
func handleConnection(conn net.Conn) {
	defer conn.Close()

	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		fmt.Println("Error reading:", err)
		return
	}
	fmt.Println("--- Received ---")
	fmt.Println(string(buf[:n]))

	response := "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello from Go\n"
	conn.Write([]byte(response))
}
```

With a `net.Listen("tcp", ":8080")` loop calling that per connection, `curl` behaves
exactly as it did against netcat.

**warning:**
**The bug: the POST body goes missing.** Send a body and it sometimes shows up, sometimes
does not:

```bash
curl -X POST -d '{"url":"https://example.com"}' http://localhost:8080/shorten
```

TCP is a **byte stream with no message boundaries**. One `Read` returns whatever has
arrived so far, which may be only the headers — the body can still be in flight.

The fix is to parse headers until the blank line, pull out `Content-Length`, then read
exactly that many bytes:

```go
func handleConnection(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)

	requestLine, err := reader.ReadString('\n')
	if err != nil {
		return
	}
	fmt.Print("Request: ", requestLine)

	contentLength := 0
	for {
		line, err := reader.ReadString('\n')
		if err != nil {
			return
		}
		line = strings.TrimSpace(line)
		if line == "" {
			break // blank line = end of headers
		}
		if strings.HasPrefix(strings.ToLower(line), "content-length:") {
			parts := strings.SplitN(line, ":", 2)
			contentLength, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
		}
	}

	if contentLength > 0 {
		body := make([]byte, contentLength)
		if _, err := io.ReadFull(reader, body); err != nil {
			return
		}
		fmt.Println("Body:", string(body))
	}

	conn.Write([]byte("HTTP/1.1 200 OK\r\n\r\nReceived\n"))
}
```

`io.ReadFull` keeps reading until the buffer is full, which is exactly the guarantee we
need. Every web framework you have used does this same parsing — now you can stop
writing it and let `net/http` do it.

## Step 2: Routing with net/http

Two endpoints to start: `POST /shorten` to create, `GET /{code}` to redirect.

```go
func handler(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path == "/shorten" && r.Method == http.MethodPost {
		handleShorten(w, r)
		return
	}
	code := strings.TrimPrefix(r.URL.Path, "/")
	if code != "" {
		handleRedirect(w, r, code)
		return
	}
	fmt.Fprintln(w, "URL Shortener is running")
}
```

**warning:**
**The bug: the catch-all swallows everything.** Registering `"/"` matches every path, so
anything that is not exactly `/shorten` falls through to the redirect branch:

```bash
curl -v "http://localhost:8080/shorten?url=test"
# HTTP/1.1 404 Not Found
# Short URL not found
```

The user used the wrong method and got told their short URL does not exist. Misleading
errors like this are how people give up on an API.

Handle each shape explicitly, and only treat single-segment paths as codes:

```go
func handler(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path

	if path == "/shorten" {
		if r.Method != http.MethodPost {
			http.Error(w, "Method not allowed. Use POST.", http.StatusMethodNotAllowed)
			return
		}
		handleShorten(w, r)
		return
	}

	if path == "/" {
		fmt.Fprintln(w, "URL Shortener is running")
		return
	}

	code := strings.TrimPrefix(path, "/")
	if strings.Contains(code, "/") {
		http.NotFound(w, r) // multi-segment paths are never short codes
		return
	}
	handleRedirect(w, r, code)
}
```

Now a GET to `/shorten` returns **405 Method Not Allowed**, `/shorten/extra` returns
**404**, and only `/abc1234` is treated as a lookup.

## Step 3: Generating short codes

Every URL currently gets the hardcoded code `abc123`. There are two standard approaches,
and you can feel the difference from the shell.

**Hash-based** — deterministic, so the same URL always maps to the same code:

```bash
echo -n "https://example.com" | sha256sum | cut -c1-8
# f0e6a6a3   (identical every run)
```

**Random** — a different code every time, even for the same URL:

```bash
head -c 6 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9'
# kQ7mBx2p   (different every run)
```

Hash-based gives free deduplication: submit the same URL twice, get the same short link.
That is what we will use.

```go
func generateCode(url string) string {
	hash := sha256.Sum256([]byte(url))
	return hex.EncodeToString(hash[:])
}
```

**warning:**
**The bug: the "short" code is 64 characters.** A full SHA-256 in hex is longer than most
of the URLs you are trying to shorten:

```text
http://localhost:8080/f0e6a6a3a0d6b6c1e2d3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5
```

Truncate to 7 characters — but truncating creates collisions, so we have to handle two
different URLs landing on the same prefix. Extend the code until it is unique:

```go
func generateCode(url string) string {
	hash := sha256.Sum256([]byte(url))
	return hex.EncodeToString(hash[:])[:7]
}

// resolveCode returns a code that is either free or already points at this URL.
func resolveCode(url string) string {
	code := generateCode(url)

	entry, exists := store.Get(code)
	if !exists || entry.OriginalURL == url {
		return code // free, or already ours
	}

	full := hex.EncodeToString(sha256.Sum256([]byte(url))[:])
	for length := 8; length <= len(full); length++ {
		code = full[:length]
		if e, taken := store.Get(code); !taken || e.OriginalURL == url {
			break
		}
	}
	return code
}
```

Same URL in, same 7-character code out. Different URLs that collide get a slightly longer
code instead of overwriting each other.

**tip:**
**Hex wastes space.** Seven hex characters give 16⁷ ≈ **268 million** codes. Seven
**base62** characters (`0-9a-zA-Z`) give 62⁷ ≈ **3.5 trillion** — about 13,000× the
address space at exactly the same visual length. Every production shortener uses base62
or similar. Hex is used here because it falls straight out of `sha256sum` and keeps the
shell examples honest; swapping the encoder is the natural first upgrade.

## Step 4: Persistence

Everything lives in a map, so a restart loses every link. The simplest durable store is a
text file, one mapping per line:

```bash
echo "f0e6a6a https://example.com" >> urls.txt
grep "^f0e6a6a " urls.txt | cut -d' ' -f2-
# https://example.com
```

That works, and it is also a trap. `grep` scans the file top to bottom, so every lookup
reads everything. At 100,000 URLs and 100 requests per second that is 10 million line
reads per second — the server would crawl.

So we use both: **the file for durability, a map for lookups.** Load the file once at
startup, serve reads from memory, append new entries to disk.

```go
type URLStore struct {
	mu      sync.RWMutex
	entries map[string]*URLEntry
	file    *os.File
}

func newURLStore(filename string) (*URLStore, error) {
	s := &URLStore{entries: make(map[string]*URLEntry)}

	if f, err := os.Open(filename); err == nil {
		scanner := bufio.NewScanner(f)
		for scanner.Scan() {
			parts := strings.SplitN(scanner.Text(), " ", 2)
			if len(parts) == 2 {
				s.entries[parts[0]] = &URLEntry{OriginalURL: parts[1], CreatedAt: time.Now()}
			}
		}
		f.Close()
		if err := scanner.Err(); err != nil {
			return nil, fmt.Errorf("reading %s: %w", filename, err)
		}
	}

	f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return nil, fmt.Errorf("opening %s for write: %w", filename, err)
	}
	s.file = f
	return s, nil
}

func (s *URLStore) Save(code, url string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.entries[code] = &URLEntry{OriginalURL: url, CreatedAt: time.Now()}
	if _, err := fmt.Fprintf(s.file, "%s %s\n", code, url); err != nil {
		return fmt.Errorf("writing to data file: %w", err)
	}
	return s.file.Sync() // flush to disk before we report success
}

func (s *URLStore) Get(code string) (*URLEntry, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	entry, exists := s.entries[code]
	return entry, exists
}
```

Create a couple of links, stop the server, start it again, and the old codes still
resolve. This is the same shape Redis uses: reads from memory, writes appended to disk.

**tip:**
`file.Sync()` on every write is deliberate. Without it the mapping can sit in the OS page
cache when you return a short URL to the user — and a crash in that window hands them a
link that resolves to nothing. It costs an fsync per creation, which is the right trade
for a write-rare, read-heavy service.

## Step 5: Redirects and click analytics

The redirect is the product. Watch one at the protocol level:

```bash
curl -o /dev/null -s -w "Status: %{http_code}\nRedirect: %{redirect_url}\n" \
  http://localhost:8080/f0e6a6a
# Status: 302
# Redirect: https://example.com
```

Recording a click is a few extra fields on the entry:

```go
func (s *URLStore) RecordClick(code string, r *http.Request) {
	s.mu.Lock()
	defer s.mu.Unlock()

	entry, exists := s.entries[code]
	if !exists {
		return
	}
	entry.Clicks = append(entry.Clicks, ClickRecord{
		Timestamp: time.Now(),
		UserAgent: r.UserAgent(),
		IP:        clientIP(r),
		Referrer:  r.Referer(),
	})
}
```

**danger:**
**The bug: a 301 silently kills your analytics.** Redirect with
`http.StatusMovedPermanently` and everything looks perfect in curl. Then open the link in
a browser twice and the click count stays at **1**.

301 means *permanently* moved, so the browser caches it and goes straight to the
destination forever after. Your server never sees the second visit. The counter is not
broken — the requests genuinely never arrive.

```go
func handleRedirect(w http.ResponseWriter, r *http.Request, code string) {
	entry, exists := store.Get(code)
	if !exists {
		http.Error(w, "Short URL not found", http.StatusNotFound)
		return
	}
	store.RecordClick(code, r)
	http.Redirect(w, r, entry.OriginalURL, http.StatusFound) // 302, never 301
}
```

302 costs one round trip per click and buys accurate counts. For a shortener, whose
entire value is knowing what got clicked, that is not a close call.

## Step 6: Rate limiting

An open `POST /shorten` is an invitation to fill your disk. Give each IP a bucket of
tokens that refills on a timer:

```go
const (
	maxTokens   = 10
	resetPeriod = time.Minute
)

func (rl *RateLimiter) Allow(ip string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	bucket, exists := rl.buckets[ip]
	if !exists {
		rl.buckets[ip] = &Bucket{tokens: maxTokens - 1, lastReset: time.Now()}
		return true
	}
	if time.Since(bucket.lastReset) > resetPeriod {
		bucket.tokens = maxTokens
		bucket.lastReset = time.Now()
	}
	if bucket.tokens <= 0 {
		return false
	}
	bucket.tokens--
	return true
}
```

Prove it works:

```bash
for i in $(seq 1 15); do
  curl -s -o /dev/null -w "Request $i: HTTP %{http_code}\n" -X POST \
    -H "Content-Type: application/json" \
    -d "{\"url\":\"https://example.com/$i\"}" \
    http://localhost:8080/shorten
done
```

The first ten return `201`, the rest `429`.

**warning:**
This is a **fixed window**, not a true token bucket — all ten tokens return at once when
the minute rolls over, so a client can send 10 at 0:59 and 10 more at 1:01. Fine for
stopping casual abuse, not fine as a hard guarantee. A real token bucket refills
continuously (`tokens += elapsed * rate`).

The `buckets` map also grows forever, one entry per IP seen. In production you would evict
idle buckets or use a fixed-size cache.

## Where this design breaks

Everything above works, and it is worth being clear about where it stops working — this
is the part most tutorials skip.

| Limit | What happens | Fix |
|---|---|---|
| **One instance only** | The map is per-process. Run two copies behind a load balancer and each sees half the links | Move state to Redis, Postgres or DynamoDB |
| **Clicks are memory-only** | Analytics reset on restart; only the mappings are persisted | Append click events to their own file or a database |
| **Write lock on every redirect** | `RecordClick` takes the write lock, so redirects serialise on the hot path | Buffer clicks in a channel and flush in a goroutine |
| **The file only grows** | Deletes and updates are not possible, and startup gets slower | Periodic compaction, or a real database |
| **Hex codes** | 268M codes instead of 3.5 trillion | Base62 encoding |
| **`clientIP` and IPv6** | Splitting on the last `:` mangles IPv6 addresses | `net.SplitHostPort` |
| **No TLS** | Short links over plain HTTP | Terminate TLS at nginx or a load balancer |

None of these matter for a service you are running locally to understand the mechanics.
All of them matter the moment it takes real traffic.

## The complete service

Everything above, assembled — standard library only.

```go
package main

import (
	"bufio"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)

const (
	dataFile    = "urls.txt"
	maxTokens   = 10
	resetPeriod = time.Minute
)

type ClickRecord struct {
	Timestamp time.Time `json:"timestamp"`
	UserAgent string    `json:"user_agent"`
	IP        string    `json:"ip"`
	Referrer  string    `json:"referrer"`
}

type URLEntry struct {
	OriginalURL string
	Clicks      []ClickRecord
	CreatedAt   time.Time
}

type URLStore struct {
	mu      sync.RWMutex
	entries map[string]*URLEntry
	file    *os.File
}

type RateLimiter struct {
	mu      sync.Mutex
	buckets map[string]*Bucket
}

type Bucket struct {
	tokens    int
	lastReset time.Time
}

type ShortenRequest struct {
	URL string `json:"url"`
}

type ShortenResponse struct {
	ShortURL string `json:"short_url"`
	Code     string `json:"code"`
}

type StatsResponse struct {
	Code         string         `json:"code"`
	OriginalURL  string         `json:"original_url"`
	ClickCount   int            `json:"click_count"`
	CreatedAt    time.Time      `json:"created_at"`
	LastClicked  string         `json:"last_clicked"`
	TopReferrers map[string]int `json:"top_referrers"`
}

var (
	store   *URLStore
	limiter *RateLimiter
)

// --- store ---

func newURLStore(filename string) (*URLStore, error) {
	s := &URLStore{entries: make(map[string]*URLEntry)}

	if f, err := os.Open(filename); err == nil {
		scanner := bufio.NewScanner(f)
		count := 0
		for scanner.Scan() {
			parts := strings.SplitN(scanner.Text(), " ", 2)
			if len(parts) == 2 {
				s.entries[parts[0]] = &URLEntry{OriginalURL: parts[1], CreatedAt: time.Now()}
				count++
			}
		}
		f.Close()
		if err := scanner.Err(); err != nil {
			return nil, fmt.Errorf("reading %s: %w", filename, err)
		}
		fmt.Printf("Loaded %d URLs from %s\n", count, filename)
	}

	f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		return nil, fmt.Errorf("opening %s for write: %w", filename, err)
	}
	s.file = f
	return s, nil
}

func (s *URLStore) Save(code, url string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	s.entries[code] = &URLEntry{OriginalURL: url, CreatedAt: time.Now()}
	if _, err := fmt.Fprintf(s.file, "%s %s\n", code, url); err != nil {
		return fmt.Errorf("writing to data file: %w", err)
	}
	return s.file.Sync()
}

func (s *URLStore) Get(code string) (*URLEntry, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	entry, exists := s.entries[code]
	return entry, exists
}

func (s *URLStore) RecordClick(code string, r *http.Request) {
	s.mu.Lock()
	defer s.mu.Unlock()

	entry, exists := s.entries[code]
	if !exists {
		return
	}
	entry.Clicks = append(entry.Clicks, ClickRecord{
		Timestamp: time.Now(),
		UserAgent: r.UserAgent(),
		IP:        clientIP(r),
		Referrer:  r.Referer(),
	})
}

func (s *URLStore) Count() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return len(s.entries)
}

// --- codes ---

func generateCode(url string) string {
	hash := sha256.Sum256([]byte(url))
	return hex.EncodeToString(hash[:])[:7]
}

func resolveCode(url string) string {
	code := generateCode(url)
	if entry, exists := store.Get(code); !exists || entry.OriginalURL == url {
		return code
	}
	sum := sha256.Sum256([]byte(url))
	full := hex.EncodeToString(sum[:])
	for length := 8; length <= len(full); length++ {
		code = full[:length]
		if e, taken := store.Get(code); !taken || e.OriginalURL == url {
			break
		}
	}
	return code
}

// --- rate limiting ---

func newRateLimiter() *RateLimiter {
	return &RateLimiter{buckets: make(map[string]*Bucket)}
}

func (rl *RateLimiter) Allow(ip string) bool {
	rl.mu.Lock()
	defer rl.mu.Unlock()

	bucket, exists := rl.buckets[ip]
	if !exists {
		rl.buckets[ip] = &Bucket{tokens: maxTokens - 1, lastReset: time.Now()}
		return true
	}
	if time.Since(bucket.lastReset) > resetPeriod {
		bucket.tokens = maxTokens
		bucket.lastReset = time.Now()
	}
	if bucket.tokens <= 0 {
		return false
	}
	bucket.tokens--
	return true
}

// --- helpers ---

func clientIP(r *http.Request) string {
	if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
		return strings.TrimSpace(strings.SplitN(fwd, ",", 2)[0])
	}
	host, _, err := net.SplitHostPort(r.RemoteAddr) // IPv6-safe
	if err != nil {
		return r.RemoteAddr
	}
	return host
}

// baseURL builds the short link from the request, so the service works on any host.
func baseURL(r *http.Request) string {
	scheme := "http"
	if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
		scheme = "https"
	}
	return fmt.Sprintf("%s://%s", scheme, r.Host)
}

// --- handlers ---

func main() {
	var err error
	if store, err = newURLStore(dataFile); err != nil {
		fmt.Println("Error initializing store:", err)
		os.Exit(1)
	}
	defer store.file.Close()

	limiter = newRateLimiter()

	http.HandleFunc("/", handler)
	fmt.Printf("URL Shortener on :8080 — %d URLs loaded\n", store.Count())
	fmt.Println("POST /shorten · GET /{code} · GET /stats/{code}")

	if err := http.ListenAndServe(":8080", nil); err != nil {
		fmt.Println("Error:", err)
	}
}

func handler(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path

	if path == "/shorten" {
		if r.Method != http.MethodPost {
			http.Error(w, "Method not allowed. Use POST.", http.StatusMethodNotAllowed)
			return
		}
		handleShorten(w, r)
		return
	}

	if path == "/" {
		fmt.Fprintln(w, "URL Shortener is running. POST to /shorten to create a short URL.")
		return
	}

	trimmed := strings.TrimPrefix(path, "/")

	if statsCode, ok := strings.CutPrefix(trimmed, "stats/"); ok {
		if statsCode != "" && !strings.Contains(statsCode, "/") {
			handleStats(w, r, statsCode)
			return
		}
	}

	if strings.Contains(trimmed, "/") {
		http.NotFound(w, r)
		return
	}
	handleRedirect(w, r, trimmed)
}

func handleShorten(w http.ResponseWriter, r *http.Request) {
	ip := clientIP(r)
	if !limiter.Allow(ip) {
		http.Error(w, "Rate limit exceeded. Max 10 URLs per minute.", http.StatusTooManyRequests)
		return
	}

	var req ShortenRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.URL == "" {
		http.Error(w, "Invalid request. Send JSON with a 'url' field.", http.StatusBadRequest)
		return
	}
	if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
		http.Error(w, "URL must start with http:// or https://", http.StatusBadRequest)
		return
	}

	code := resolveCode(req.URL)
	resp := ShortenResponse{
		ShortURL: fmt.Sprintf("%s/%s", baseURL(r), code),
		Code:     code,
	}
	w.Header().Set("Content-Type", "application/json")

	// Already shortened: return the existing link instead of rewriting it.
	if entry, exists := store.Get(code); exists && entry.OriginalURL == req.URL {
		json.NewEncoder(w).Encode(resp)
		return
	}

	if err := store.Save(code, req.URL); err != nil {
		http.Error(w, "Failed to save URL", http.StatusInternalServerError)
		return
	}
	fmt.Printf("CREATE   /%s -> %s\n", code, req.URL)

	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(resp)
}

func handleRedirect(w http.ResponseWriter, r *http.Request, code string) {
	entry, exists := store.Get(code)
	if !exists {
		http.Error(w, "Short URL not found", http.StatusNotFound)
		return
	}
	store.RecordClick(code, r)
	fmt.Printf("REDIRECT /%s -> %s from %s\n", code, entry.OriginalURL, clientIP(r))

	http.Redirect(w, r, entry.OriginalURL, http.StatusFound)
}

func handleStats(w http.ResponseWriter, r *http.Request, code string) {
	entry, exists := store.Get(code)
	if !exists {
		http.Error(w, "Short URL not found", http.StatusNotFound)
		return
	}

	store.mu.RLock()
	clicks := append([]ClickRecord(nil), entry.Clicks...) // copy, do not hold the lock
	created := entry.CreatedAt
	original := entry.OriginalURL
	store.mu.RUnlock()

	lastClicked := "never"
	if len(clicks) > 0 {
		lastClicked = clicks[len(clicks)-1].Timestamp.Format(time.RFC3339)
	}

	referrers := make(map[string]int)
	for _, c := range clicks {
		ref := c.Referrer
		if ref == "" {
			ref = "direct"
		}
		referrers[ref]++
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(StatsResponse{
		Code:         code,
		OriginalURL:  original,
		ClickCount:   len(clicks),
		CreatedAt:    created,
		LastClicked:  lastClicked,
		TopReferrers: referrers,
	})
}
```

### Running it

```bash
go run main.go

# create
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"url":"https://go.dev/doc/"}' http://localhost:8080/shorten
# {"short_url":"http://localhost:8080/a1b2c3d","code":"a1b2c3d"}

# click it a few times
curl -s -o /dev/null http://localhost:8080/a1b2c3d

# stats
curl -s http://localhost:8080/stats/a1b2c3d
```

## What you actually learned

The shortener is the excuse. The transferable parts are: HTTP is text over a byte stream
with no message boundaries; a catch-all route will happily hide your real errors;
truncating a hash means owning the collision case; memory plus an append-only file is a
legitimate storage design; **301 versus 302 is an analytics decision, not a style
choice**; and any open write endpoint needs a limiter in front of it.

Every line here is standard library. No frameworks, no database, no dependencies.

## Keep Reading

- [Go + Nginx: Deploy a Go API Behind a Reverse Proxy](https://karandeepsingh.ca/posts/go-nginx-reverse-proxy/) — put this behind nginx with TLS.
- [Containers From Scratch in Go](https://karandeepsingh.ca/posts/containers-from-scratch-go/) — package it up.
- [Go + DynamoDB: Build a Simple CRUD App](https://karandeepsingh.ca/posts/go-dynamodb-crud/) — replace the file store so it can run on more than one instance.

## Quiz

1. Why does a URL shortener use a 302 redirect instead of a 301?
   a) 302 is faster than 301
   b) A 301 is cached by the browser, so later visits never reach your server and your click analytics stop counting
   c) 301 is not supported by most browsers
   d) 302 uses less bandwidth
   Answer: b. 301 means "permanently moved", so browsers cache it and go straight to the destination on every later visit. Your server never sees those requests, so click counts freeze. 302 costs one extra round trip but keeps analytics accurate.

2. Your TCP server reads a request with a single 1024-byte Read call and the POST body is sometimes missing. Why?
   a) The body is encrypted
   b) TCP is a stream, so headers and body can arrive in separate packets — one Read may return only the headers
   c) Go limits reads to 1024 bytes
   d) curl does not send the body
   Answer: b. TCP delivers a byte stream with no message boundaries. A single Read returns whatever has arrived so far. You have to parse the headers, find Content-Length, and then read exactly that many bytes for the body.

3. Seven hex characters give roughly 268 million short codes. What does switching to base62 buy you?
   a) Nothing, the codes are the same length
   b) Roughly 3.5 trillion codes at the same 7-character length, because each character carries 62 values instead of 16
   c) Shorter URLs but more collisions
   d) Case-insensitive codes
   Answer: b. Hex uses 16 symbols per character (16^7 ≈ 268M). Base62 uses 62 (62^7 ≈ 3.5 trillion). Same visual length, roughly 13,000x more address space, so collisions become far less likely.


