net/go.book
All Parts Marketing

Performance Optimization: Concepts and Go Implementation

"Tuning a race car before you've measured its lap times is just guessing with extra steps. Performance work on a network service follows the same rule: profile first, find the actual bottleneck, then change one thing at a time — otherwise you risk making the code more complicated without making it any faster."


Where Network Applications Actually Lose Time

Every server built in this book does some combination of four things, and the bottleneck is almost always one of them:

  • I/O waiting: Time spent blocked on the network or disk, not doing useful CPU work.
  • Memory allocation and GC pressure: Every allocation is eventually cleaned up by the garbage collector; a hot path that allocates constantly makes the GC work harder, stealing CPU time from your actual logic.
  • Connection overhead: Repeatedly setting up and tearing down connections (TCP handshakes, TLS handshakes) instead of reusing them.
  • Goroutine misuse: Either too few goroutines serializing work that could run in parallel, or so many that scheduling and memory overhead start to dominate.

The pprof tooling in the standard library measures all four without guesswork.

The fastest optimization is the one you don't make — every line of "faster" code you add is also a line of code that can be wrong. Measure first, then earn the complexity.


Profiling with net/http/pprof

Importing net/http/pprof for its side effects wires up a set of profiling endpoints on any net/http server:

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	go func() {
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()

	// ... the rest of your application's real server setup ...
	select {}
}

Try It Yourself: wire net/http/pprof into one of your own TCP or HTTP servers from earlier chapters, generate some artificial load against it, and capture both a CPU and a heap profile to see which functions actually show up. There's no bundled exercise file for this chapter yet — the snippet above is everything you need to start.

With that running, go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 captures a 30-second CPU profile, and go tool pprof http://localhost:6060/debug/pprof/heap captures a memory snapshot — both open into an interactive explorer that shows exactly which functions are consuming CPU or allocating memory.

Beyond CPU and Heap

net/http/pprof wires up several profiles beyond the two most commonly reached for:

  • /debug/pprof/goroutine — every goroutine's stack, useful for spotting leaks as much as for performance.
  • /debug/pprof/block — time spent blocked on channels and mutexes. Disabled by default; enable with runtime.SetBlockProfileRate(1).
  • /debug/pprof/mutex — which mutexes are most contended. Enable with runtime.SetMutexProfileFraction(1).
  • /debug/pprof/trace — a fine-grained execution trace, viewable with go tool trace.

Don't expose pprof publicly
The /debug/pprof/ endpoints reveal internal details (goroutine stacks, memory layout) and can be used to trigger expensive profiling on demand. Bind them to a localhost-only listener, as above, or gate them behind authentication — never leave them reachable from the public internet.

Profiling an idle server tells you nothing
A CPU profile captured while the server has no real traffic mostly shows time spent waiting, not computing. Generate representative load first (a benchmark client, or hey/vegeta hitting the server) and capture the profile during that load, or the time spent capturing it is wasted.


Benchmarking with testing.B

Before optimizing anything, get a repeatable number. Go's testing package (already used for correctness tests in the previous chapter) also runs benchmarks:

func BenchmarkParseHeader(b *testing.B) {
	data := []byte{0, 0, 0, 0, 0, 0, 0, 10}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		parseHeader(data)
	}
}

Run it with go test -bench=. -benchmem, which reports iterations per second alongside allocations per operation — the -benchmem flag is what surfaces the GC-pressure story, not just raw speed.

Don't let setup cost pollute the measurement
b.ResetTimer() exists because anything before it — building test fixtures, opening a file, generating sample data — would otherwise count against the benchmark's reported time. Any one-time setup that isn't part of what you're actually measuring belongs before ResetTimer; anything inside the loop is fair game and should be exactly what you're trying to measure, nothing more.

Benchmarking Concurrent Code

A single-goroutine benchmark like BenchmarkParseHeader doesn't tell you how a function behaves under the same concurrent load a real server subjects it to — lock contention and cache effects only show up with multiple goroutines calling it at once. b.RunParallel runs the benchmark body across GOMAXPROCS goroutines automatically:

func BenchmarkParseHeaderParallel(b *testing.B) {
	data := []byte{0, 0, 0, 0, 0, 0, 0, 10}
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			parseHeader(data)
		}
	})
}

Each goroutine loops on pb.Next() until the benchmark's total iteration budget is exhausted, and -benchmem still reports allocations the same way — this is the benchmark equivalent of the worker pool pattern later in this chapter, and it's the right tool whenever the function under test is going to be called concurrently in production.


Reducing Allocations with sync.Pool

A server that allocates a fresh buffer for every request or every connection creates constant garbage for the collector to clean up. sync.Pool lets you reuse buffers instead of discarding them:

var bufferPool = sync.Pool{
	New: func() any {
		return make([]byte, 4096)
	},
}

func handleConn(conn net.Conn) {
	defer conn.Close()

	buf := bufferPool.Get().([]byte)
	defer bufferPool.Put(buf)

	for {
		n, err := conn.Read(buf)
		if err != nil {
			return
		}
		process(buf[:n])
	}
}

Each call to Get returns either a previously used buffer or a newly allocated one if the pool is empty; Put returns it for reuse instead of letting it become garbage. This directly reduces the allocation count a benchmark's -benchmem output would report.

Reset pooled objects before returning them
The []byte buffer above is safe to reuse because every reader treats buf[:n] as the only valid region. That's not true for every type: a pooled *bytes.Buffer or a struct with slice/map fields can leak the previous caller's data into the next one if Put back without clearing it. Reset state explicitly first: go func handleRequest(payload *bytes.Buffer) { defer func() { payload.Reset() // clear before returning to the pool bufferObjPool.Put(payload) }() // ... use payload ... } Skipping Reset here doesn't just risk performance — it risks one request silently reading data left behind by a completely unrelated previous request, which is a correctness bug, not just an inefficiency.


Reusing Connections

Every TCP or TLS handshake costs round trips before a single byte of application data moves. The HTTP chapter's http.Client already does this for you by default — its underlying http.Transport keeps idle connections open and reuses them for subsequent requests to the same host. Tuning that pool matters under load:

client := &http.Client{
	Transport: &http.Transport{
		MaxIdleConns:        100,
		MaxIdleConnsPerHost: 20,
		IdleConnTimeout:     90 * time.Second,
	},
	Timeout: 10 * time.Second,
}

Raising MaxIdleConnsPerHost above its conservative default matters specifically when your client makes many concurrent requests to the same backend, since the default caps how many idle connections are kept ready per host before new connections must be dialed from scratch.

An unclosed response body defeats connection reuse
http.Transport can only put a connection back in its idle pool once the previous response's body has been fully read and closed. Code that checks resp.StatusCode and returns without calling resp.Body.Close() leaks the connection: Transport must dial a brand-new one for the next request, silently erasing the benefit of the pooling shown above. The safe pattern is always: go resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) // ensure the body is drained Draining matters even when you don't care about the content — a partially-read large body more often forces Transport to discard the connection rather than reuse it, while a small, fully-drained body is safely returned to the pool.


Buffered I/O

Repeatedly calling Read/Write with tiny amounts of data means a system call per call, and system calls aren't free. bufio.Reader/bufio.Writer — wrapping any io.Reader/io.Writer, including a net.Conn — batch small reads and writes into fewer, larger system calls:

reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
defer writer.Flush() // buffered writes must be flushed explicitly

line, err := reader.ReadString('\n')

io.Copy, used throughout the file transfer chapter, already performs its own internal chunked buffering — the manual wrapping above matters most for line-oriented or small-message protocols where you're issuing many small reads yourself.

Forgetting to flush loses data, not just performance
Nothing written to a bufio.Writer reaches the connection until the internal buffer fills or Flush is called explicitly. A handler that writes a response and returns without calling writer.Flush() can leave data sitting in memory that never reaches the client, especially if the connection closes right after. Always defer writer.Flush() right after creating the writer, as above, so it runs even on an early return.

Sizing the buffer to match your protocol also matters: the default bufio.NewReader/NewWriter size is 4096 bytes, but bufio.NewReaderSize/NewWriterSize let you pick a size that matches your typical message — too small and you're back to frequent syscalls, too large and every connection wastes memory on a buffer it rarely fills.


Goroutines and GOMAXPROCS

The concurrency chapter's deep dive explained Go's M:N scheduler: goroutines are multiplexed onto a small number of OS threads, controlled by GOMAXPROCS (defaulting to the number of CPU cores). For most network servers, the default is correct and shouldn't be touched — the scheduler already parks goroutines blocked on I/O and runs others in their place. The more common performance mistake is launching an unbounded goroutine per unit of work with no limit, which can exhaust memory under a traffic spike. A worker pool — a fixed number of goroutines pulling from a shared channel — bounds concurrency explicitly:

func worker(jobs <-chan Job, results chan<- Result) {
	for job := range jobs {
		results <- process(job)
	}
}

func main() {
	jobs := make(chan Job, 100)
	results := make(chan Result, 100)

	for i := 0; i < 10; i++ { // fixed pool of 10 workers
		go worker(jobs, results)
	}
	// ... feed jobs, collect results ...
}

Try It Yourself

Benchmark the worker pool above two ways with b.RunParallel: once through the bounded pool of 10 workers, and once launching an unbounded go process(job) per job. Compare -benchmem output and the goroutine profile as concurrent jobs grow into the thousands — the unbounded version's problem shows up as scheduler and memory overhead long before it shows up as a crash, which is why it's easy to miss until a real traffic spike finds it.


Frequently Asked Questions

I made a change that should be faster, but I didn't profile first — is that a problem? It usually is, even when the change seems obviously correct. This chapter's whole premise is that intuition about where time goes in a network service is unreliable — I/O waiting, GC pressure, connection overhead, and goroutine misuse all masquerade as each other from the outside. A change made without a profile to justify it can easily add complexity for a bottleneck that was never the real one, which is exactly the trap the opening quote warns against.

Why does sync.Pool sometimes seem to not help at all, or even get slower? Because objects sitting in a sync.Pool can be dropped by the garbage collector at any time, including between two Get calls in the same request — it's built purely to reduce allocation churn under load, not to guarantee reuse. Under light or bursty traffic the pool may end up allocating almost as often as if it weren't there at all; its benefit shows up specifically under sustained load with steady buffer turnover, which is why -benchmem before and after is the only reliable way to tell if it's actually paying for itself.

My benchmark shows a huge speedup, but production didn't get any faster — why? A single-goroutine benchmark like BenchmarkParseHeader doesn't capture lock contention or cache effects that only appear once many goroutines call the function concurrently, which is exactly what a real server does. Reach for b.RunParallel to benchmark under the same kind of concurrent pressure your production traffic actually creates, rather than trusting a single-threaded number to predict multi-goroutine behavior.

Should I just raise GOMAXPROCS if my server feels slow? Rarely — for most network servers the default (matched to detected CPU cores) is already correct, since the scheduler parks goroutines blocked on I/O and runs others in their place without your help. The more common real mistake is launching an unbounded goroutine per unit of work with no cap, which a worker pool fixes directly. The one case worth double-checking is inside a container, where GOMAXPROCS can default to the host's full CPU count even though a cgroup limits you to a fraction of it — that's what automaxprocs exists to correct.

Does connection reuse matter for a client that only makes one request occasionally? Not much — the benefit of http.Transport's idle pool compounds specifically when a client makes many requests to the same backend, since it avoids repeating TCP and TLS handshakes and avoids exhausting ephemeral ports. A client that fires off one infrequent request pays the handshake cost once either way, so tuning MaxIdleConnsPerHost upward only pays off once you're actually issuing concurrent or frequent requests to one host.

Key Takeaways

  • Profile before optimizing: net/http/pprof and go tool pprof locate the actual bottleneck instead of a guessed one.
  • testing.B benchmarks with -benchmem turn "is this faster?" into a measured, repeatable number.
  • sync.Pool reduces GC pressure by reusing short-lived buffers, but never treat it as a cache.
  • Reuse connections (http.Transport tuning) and batch small I/O operations (bufio) to cut down on handshake and syscall overhead.
  • Bound goroutine growth with a worker pool rather than launching one goroutine per unit of work without limit.
  • Always close (and drain) resp.Body, or http.Transport can't reuse the connection at all — silently erasing the benefit of connection pooling.
  • Reset pooled objects before Put; a sync.Pool optimization that leaks one caller's data into another's request is a correctness bug, not just a slowdown.
  • Profile under representative load, not an idle server, and consider automaxprocs if the service runs inside a container.