DEV Community

Cover image for Go, from zero to a running service · 1. Why Go exists and what it optimises for
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Go, from zero to a running service · 1. Why Go exists and what it optimises for

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
Enter fullscreen mode Exit fullscreen mode

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 with go 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
Enter fullscreen mode Exit fullscreen mode

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/uuid for 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" ./...
Enter fullscreen mode Exit fullscreen mode

You will see messages like:

./handler.go:12:6: moved to heap: requestBody (escapes to heap)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Typical output on a 2023 laptop:

BenchmarkStdMux-8    1 200 000    945 ns/op    0 B/op    0 allocs/op
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Create the module
   mkdir txnsvc && cd txnsvc
   go mod init example.com/txnsvc
Enter fullscreen mode Exit fullscreen mode

Why: Modules give you reproducible builds; go.mod pins the Go version (1.22) and any future dependencies.

  1. 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
Enter fullscreen mode Exit fullscreen mode

Why: Using concrete structs eliminates reflection; json.NewDecoder reuses the request body buffer, avoiding extra allocations.

  1. Compile with optimisation flags
   go build -ldflags="-s -w" -o txnsvc .
Enter fullscreen mode Exit fullscreen mode

Why: -s -w strips symbol tables and DWARF debugging info, reducing binary size from ~6 MiB to ~3 MiB, which improves cold‑start time.

  1. Run and verify
   ./txnsvc &
   curl -s -X POST -d '{"id":"abc123","amount":100}' localhost:8080/txn
   curl -s localhost:8080/healthz
Enter fullscreen mode Exit fullscreen mode

Expected output:

   {"status":"ok"}
   (empty body, HTTP 200)
Enter fullscreen mode Exit fullscreen mode
  1. Check allocation profile
   go tool pprof -http=:8081 http://localhost:8080/debug/pprof/heap
Enter fullscreen mode Exit fullscreen mode

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

  1. Escape‑analysis audit

    Clone the repository github.com/golang/example/hello. Add a function that builds a large slice inside a handler and returns it. Run go build -gcflags="-m" and identify which variables escape. Refactor to keep them on the stack. Done when go build -gcflags="-m" reports no heap escapes for the handler.

  2. Standard‑library vs third‑party router benchmark

    Implement the same /ping endpoint using net/http and github.com/julienschmidt/httprouter. Benchmark both with go 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.

  3. GC pause profiling under load

    Deploy the txnsvc binary from the “Build it” section to a local Docker container. Run hey -n 50000 -c 200 http://localhost:8080/txn while go tool pprof -http=:8081 http://localhost:8080/debug/pprof/trace?seconds=30. Identify the peak GC pause and reduce it by adding a sync.Pool for request buffers. Done when the maximum pause drops below 0.5 ms.

  4. Binary size optimisation

    Add a harmless import of github.com/google/uuid to main.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.

  5. Error‑handling refactor

    Write a handler that deliberately uses panic for 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 returns 400 Bad Request instead of a 500 and 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)