/user/kayd @ devops :~$ cat url-shortener-from-scratch-go.md

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

QR Code linking to: Building a URL Shortener in Go: From TCP Sockets to a Working Service
Karandeep Singh
Karandeep Singh
• 16 minutes

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:

    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:

nc -l 8080

In the second:

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

The first terminal prints the raw request:

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:

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

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:

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:

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.

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:

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:

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:

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

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

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.

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:

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:

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:

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.

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.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

Step 5: Redirects and click analytics

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

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:

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(),
	})
}
caution

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.

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:

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:

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.

LimitWhat happensFix
One instance onlyThe map is per-process. Run two copies behind a load balancer and each sees half the linksMove state to Redis, Postgres or DynamoDB
Clicks are memory-onlyAnalytics reset on restart; only the mappings are persistedAppend click events to their own file or a database
Write lock on every redirectRecordClick takes the write lock, so redirects serialise on the hot pathBuffer clicks in a channel and flush in a goroutine
The file only growsDeletes and updates are not possible, and startup gets slowerPeriodic compaction, or a real database
Hex codes268M codes instead of 3.5 trillionBase62 encoding
clientIP and IPv6Splitting on the last : mangles IPv6 addressesnet.SplitHostPort
No TLSShort links over plain HTTPTerminate 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.

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

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

Similar Articles

More from development

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.