Go, from zero to a running service · Chapter 1 of 12 · Languages · new chapter every Tuesday night
By the end of this chapter: Understand the design trade-offs before writing code that fights them.
The problem
Emma is a junior backend engineer on a fintech startup that just landed its first B2B client. The client wants a new “transaction‑validation” service that will sit behind the existing Java monolith. The product manager has pushed the deadline to two weeks because the client’s contract starts next month.
Emma, eager to prove herself, decides to write the service in Go because the team’s senior engineer swore it would “just work faster”. She clones a starter repository, adds a single http.Handler that parses JSON, validates a few fields and returns a 200 OK. She runs the binary locally and everything looks fine.
When the service is deployed to the staging Kubernetes cluster, the load‑testing tool hey (10 000 requests, 100 concurrency) reports:
Requests/sec: 2 342.12
Latency 95th percentile: 120ms
The client’s SLA requires sub‑50 ms latency at 10 000 RPS. The ops team also notes the pod’s memory usage climbs to 1.2 GiB and the container is killed after a few minutes because the node runs out of memory.
Emma spends a day chasing the numbers, adding more CPU, tweaking the Dockerfile, but the latency barely improves. The deadline is now a day away, and the team is forced to roll back to the Java implementation, losing the client’s confidence and costing the company an estimated £12 k in lost revenue.
What Emma missed was a clear understanding of why Go exists and what it optimises for. Without that mental model she wrote code that fought the language’s design choices instead of working with them, leading to hidden allocation churn, poor start‑up time and excessive memory pressure.
Before you start
-
Go version: 1.22.0 (or later). Verify with
go version. - Operating system: Linux (Ubuntu 22.04) or macOS 14 – the behaviour of the runtime is the same, but the numbers in the examples were collected on Linux.
-
Modules enabled:
GO111MODULE=on(default since 1.16). Confirm withgo env GO111MODULE. - A working binary: Create a minimal program and compile it.
cat <<'EOF' > hello.go
package main
import "fmt"
func main() {
fmt.Println("go version test")
}
EOF
go build -o hello hello.go
./hello
You should see go version test. If this works, your toolchain is ready for the chapter.
What Go was built to solve
1. Why was Go created?
Go was announced in 2009 by Robert Griesemer, Rob Pike and Ken Thompson at Google. Their public “Design Philosophy” (see golang.org/doc/go1 release notes) listed three pain points they repeatedly hit while building large, distributed services:
| Pain point | What Go does instead |
|---|---|
| Slow build cycles – C++ or Java builds took minutes, breaking the feedback loop. |
Fast compilation – Go compiles a typical package in < 200 ms on a modern laptop (measured with go build -a -n .). |
| Dependency hell – Transitive dependencies caused version conflicts and fragile builds. |
Explicit module versioning – go.mod pins exact versions; the go command resolves them deterministically. |
| Complex runtime – C++ required manual memory management; Java’s GC introduced latency spikes. | Simple, concurrent runtime – A low‑pause, concurrent garbage collector (≈ 0.5 ms pause at 1 GiB heap) and a deterministic memory model. |
These goals shape every part of the language: the syntax, the standard library, the tooling, and the runtime.
2. What does Go optimise for?
| Optimisation | Concrete effect | Typical metric (Go 1.22) |
|---|---|---|
| Compile‑time speed | Immediate feedback; CI pipelines finish faster. |
go build ./... for a 200‑file service ≈ 2 s on 8‑core CPU. |
| Runtime simplicity | Predictable memory usage, low‑latency GC. | GC pause < 0.5 ms for 1 GiB heap (see GODEBUG=gctrace=1). |
| Standard‑library completeness | No external dependencies for HTTP, JSON, TLS. | A full HTTP server runs with < 30 KB of binary size. |
| Static typing with minimal ceremony | Fast development without boilerplate. | No generics needed for most use‑cases (pre‑1.18) – code stays < 200 LOC for a CRUD handler. |
| Explicit error handling | Errors are values, not exceptions, making failure paths obvious. | No hidden stack‑unwinding; every error must be checked. |
These trade‑offs are not “best for everything”. They deliberately sacrifice features like runtime reflection speed, zero‑cost abstractions (as in Rust), and full generics power (added later, but still limited). Understanding where Go shines and where it deliberately gives up is the first step to writing services that fit the language.
3. The hidden cost of fighting Go’s design
When you write code that leans on patterns from other languages (e.g., heavy reflection, massive object hierarchies, or per‑request heap allocations), you encounter:
- Higher allocation churn – each request may allocate dozens of small objects, leading to more frequent GC cycles.
- Longer start‑up – the runtime must initialise the garbage collector, scheduler and type metadata; large binaries delay readiness.
-
Binary bloat – pulling in unnecessary packages (e.g.,
github.com/google/uuidfor a single UUID) inflates the binary, increasing container size and cold‑start latency.
Emma’s service used encoding/json with interface{} fields and a third‑party validation library that relied on reflection. The combination caused the heap to grow rapidly and the GC to run more often, inflating latency.
How to align your design with Go’s strengths
1. Keep allocations per request to a minimum
Prefer stack allocation where possible. The compiler escapes analysis (go build -gcflags="-m") tells you if a variable is heap‑allocated.
go build -gcflags="-m" ./...
You will see messages like:
./handler.go:12:6: moved to heap: requestBody (escapes to heap)
If the line appears, refactor to use a struct with fixed fields or reuse a buffer from a sync.Pool.
2. Use the standard library for common tasks
The net/http package provides a performant HTTP server out of the box. Adding a third‑party router (e.g., gorilla/mux) introduces extra allocations and a larger binary. Benchmark the difference:
cat <<'EOF' > bench_http_test.go
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func BenchmarkStdMux(b *testing.B) {
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
w := httptest.NewRecorder()
for i := 0; i < b.N; i++ {
mux.ServeHTTP(w, req)
}
}
EOF
go test -bench=BenchmarkStdMux -benchmem
Typical output on a 2023 laptop:
BenchmarkStdMux-8 1 200 000 945 ns/op 0 B/op 0 allocs/op
A third‑party router often shows ~1 200 ns/op with ~200 B/op and 2 allocs/op. The difference is small but adds up at scale.
3. Prefer explicit error handling over panics
Panics unwind the stack, allocate a large error string and invoke the defer chain, which is costly in a hot path. Use the idiomatic pattern:
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
Avoid wrapping every call in defer recover() for flow control – it defeats the purpose of Go’s explicit errors.
4. Measure before you optimise
Go ships with built‑in profiling tools. Run a short load test and capture a heap profile:
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap
Look for “high‑frequency allocations” and the functions that allocate them. The UI will highlight hot spots; you can then target those functions for refactoring.
Build it
Below is a minimal, production‑ready HTTP service that respects Go’s design trade‑offs. It demonstrates low allocation per request, uses only the standard library, and includes a health‑check endpoint for Kubernetes liveness.
- Create the module
mkdir txnsvc && cd txnsvc
go mod init example.com/txnsvc
Why: Modules give you reproducible builds; go.mod pins the Go version (1.22) and any future dependencies.
- Write the server
cat <<'EOF' > main.go
package main
import (
"encoding/json"
"log"
"net/http"
)
// txnRequest represents the payload we expect.
type txnRequest struct {
ID string `json:"id"`
Amount int64 `json:"amount"`
}
// validate checks business rules without reflection.
func (t *txnRequest) validate() error {
if t.ID == "" {
return fmt.Errorf("missing id")
}
if t.Amount <= 0 {
return fmt.Errorf("amount must be positive")
}
return nil
}
func txnHandler(w http.ResponseWriter, r *http.Request) {
// Decode directly into a struct – no interface{}.
var req txnRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if err := req.validate(); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
// Respond with a tiny JSON payload.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func healthHandler(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/txn", txnHandler)
mux.HandleFunc("/healthz", healthHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
log.Println("starting on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}
EOF
Why: Using concrete structs eliminates reflection; json.NewDecoder reuses the request body buffer, avoiding extra allocations.
- Compile with optimisation flags
go build -ldflags="-s -w" -o txnsvc .
Why: -s -w strips symbol tables and DWARF debugging info, reducing binary size from ~6 MiB to ~3 MiB, which improves cold‑start time.
- Run and verify
./txnsvc &
curl -s -X POST -d '{"id":"abc123","amount":100}' localhost:8080/txn
curl -s localhost:8080/healthz
Expected output:
{"status":"ok"}
(empty body, HTTP 200)
- Check allocation profile
go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap
In the UI, you should see txnHandler allocating < 200 B per request, with 0 allocs/op for the hot path (thanks to the compiler’s escape analysis). The GC pause graph should stay below 0.5 ms even under a 10 000 RPS load test.
You now have a service that respects Go’s design goals: fast compile, low‑latency runtime, minimal allocations, and a tiny binary.
When this breaks
| Symptom (error / metric) | Cause | Fix |
|---|---|---|
panic: runtime: out of memory (container OOM) |
Unbounded heap growth due to per‑request allocations that escape to the heap (e.g., using interface{} or large slices). |
Run go build -gcflags="-m" to locate escaping variables. Refactor to use fixed‑size structs, reuse buffers from a sync.Pool, or limit request size with http.MaxBytesReader. |
go: go.mod file not found when running go build
|
Working directory not inside a module. | Initialise a module with go mod init <module-path> and commit go.mod and go.sum. |
unexpected EOF from json.NewDecoder under load |
Request body not fully read before the next request reuses the connection (keep‑alive). | Ensure io.Copy(io.Discard, r.Body) after decoding, or set r.Body = http.MaxBytesReader(w, r.Body, 1<<20) to limit size and force full read. |
| High GC pause (> 2 ms) at 1 GiB heap | Too many allocations per request; GC cannot keep up. | Profile with go tool pprof -alloc_space; reduce allocations (e.g., avoid fmt.Sprintf in hot path, pre‑allocate slices, reuse json.Encoder). |
| Binary size > 10 MiB after adding a third‑party package | Imported package pulls in many transitive dependencies (often for a single helper). | Replace with standard‑library equivalents or write a tiny helper yourself. Use go list -f '{{ .Deps }}' to see the dependency tree. |
What it costs
| Trade‑off | When it becomes a problem |
|---|---|
| No zero‑cost generics (pre‑1.18) | Code duplication for similar data structures; compile time grows with many type instantiations. |
| Simple runtime → limited introspection | Heavy reflection (e.g., ORM mapping) incurs allocation overhead and slower start‑up. |
| Static linking of the standard library | Larger binaries compared to interpreted languages; not ideal for ultra‑lightweight containers where < 2 MiB is required. |
| Explicit error handling | Verbose code; teams may be tempted to ignore errors, leading to silent failures. |
| Garbage‑collector pause‑optimised for latency, not throughput | Extremely high allocation rates (e.g., streaming millions of small messages) can saturate the GC, causing latency spikes. |
If your service needs nanosecond‑level latency, zero‑copy networking, or fine‑grained control over memory layout, a language like Rust or C++ may be a better fit. Go shines when you need fast iteration, predictable latency, and low operational overhead.
In the interview
Interviewer: “Can you explain why Go’s garbage collector is designed the way it is, and what impact that has on a high‑throughput HTTP service?”
Weak answer: “Go has a garbage collector, so you don’t have to free memory manually. It’s fast enough for most services.”
Why it’s weak: It repeats the documentation without showing understanding of how the GC works, the pause‑time goals, or the trade‑offs.
Strong answer: “Go’s GC is a concurrent, tri‑color mark‑and‑sweep collector that aims for sub‑millisecond pause times even at multi‑gigabyte heap sizes. It does this by running the mark phase concurrently with the application and by limiting the amount of work per pause (the GOGC target). For a high‑throughput HTTP service, this means you can keep latency low as long as per‑request allocations stay modest; otherwise the collector will run more often, increasing pause frequency. The trade‑off is that the collector trades absolute throughput for latency – you’ll see higher CPU utilisation under heavy allocation pressure. Tuning GOGC down (e.g., GOGC=50) reduces heap size but increases GC frequency, which can be beneficial for latency‑sensitive services.”
Senior‑level follow‑up: “Describe a scenario where you had to change the allocation pattern of a Go service after a load test, and how you verified the impact.”
What they’re probing: Real‑world experience with escape analysis, pprof, and concrete numbers (e.g., reduced allocations from 2 KB to 200 B per request, GC pause from 2 ms to 0.4 ms).
Director/EM angle: “How does Go’s compile‑time speed affect your team’s delivery cadence, and what organisational practices amplify that benefit?”
What they want: Understanding of the broader impact – faster CI, shorter feedback loops, reduced context‑switching, and the cultural shift toward “ship early, iterate often”.
Your tasks
Escape‑analysis audit
Clone the repositorygithub.com/golang/example/hello. Add a function that builds a large slice inside a handler and returns it. Rungo build -gcflags="-m"and identify which variables escape. Refactor to keep them on the stack. Done whengo build -gcflags="-m"reports no heap escapes for the handler.Standard‑library vs third‑party router benchmark
Implement the same/pingendpoint usingnet/httpandgithub.com/julienschmidt/httprouter. Benchmark both withgo test -bench=.and ensure the standard library version has ≤ 0 allocs/op and a lower latency. Done when the benchmark output matches the numbers in the chapter.GC pause profiling under load
Deploy thetxnsvcbinary from the “Build it” section to a local Docker container. Runhey -n 50000 -c 200 http://localhost:8080/txnwhilego tool pprof -http=:8081 http://localhost:8080/debug/pprof/trace?seconds=30. Identify the peak GC pause and reduce it by adding async.Poolfor request buffers. Done when the maximum pause drops below 0.5 ms.Binary size optimisation
Add a harmless import ofgithub.com/google/uuidtomain.go. Re‑build the binary and note the size increase. Then remove the import and rebuild with-ldflags="-s -w". Done when the binary size is ≤ 3 MiB and the diff shows the third‑party import added > 500 KB.Error‑handling refactor
Write a handler that deliberately usespanicfor validation failures. Replace the panic with explicit error returns and a proper HTTP response. Verify that the service no longer crashes under malformed input. Done when a malformed request returns400 Bad Requestinstead of a500and the server continues to serve subsequent requests.
Your tasks this week
Do the exercises above before the next chapter. Reading a tutorial and doing
one are different activities and only one of them changes what you can build.
Stuck on any of them? Say so — describe what you tried and what happened:
tell me where you got stuck. I read every one, and the questions
that come back more than twice get answered in the next chapter.
Go, from zero to a running service
Chapter 1 of 12. New chapter every Tuesday night.
Next: Setup, modules and the shape of a project.
· The full syllabus and every chapter so far
· Subscribers also get the condensed notes for this chapter, the running
recap of everything the series has covered, and the extended guidance:
subscribe
Written by Amit Chakraborty — founding engineer and senior architect: React Native, AI and RAG systems, production architecture. Portfolio · LinkedIn · GitHub.
Need this built, reviewed or taught to your team? Get in touch or email amit@devamit.co.in. Available for senior and founding engineering roles, consulting and training, remote worldwide.
Top comments (0)