Understanding Go Server Profiling
Profiling is the systematic measurement of a program's runtime behavior—CPU usage, memory allocation, goroutine activity, and blocking patterns. For Go servers handling thousands of concurrent connections, profiling reveals exactly where time and resources are spent, transforming guesswork into data-driven optimization.
What Profiling Measures
Go's profiling ecosystem captures four primary dimensions of server performance:
- CPU Profile: Records the call stack at frequent intervals, showing which functions consume the most processor time
- Heap Profile: Tracks memory allocations, revealing which code paths allocate the most bytes and objects
- Goroutine Profile: Captures the stack traces of all running goroutines, exposing leaks and stalled workers
- Block Profile: Measures time spent blocked on synchronization primitives like mutexes and channels
- Mutex Profile: Specifically tracks contention on mutex locks, identifying serialization bottlenecks
Why Profiling Matters for Production Servers
Without profiling, developers often optimize code that looks expensive but contributes minimally to actual latency. A 50-line function may seem inefficient, yet consume only 0.1% of CPU time, while a seemingly innocent JSON deserialization call eats 40% of total CPU. Profiling eliminates this blind spot. For production Go servers, the stakes are compounded: a single inefficient code path multiplied by thousands of concurrent requests can saturate CPU cores, exhaust memory, or stall the entire runtime scheduler.
Setting Up Profiling in a Go Server
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The net/http/pprof package provides the simplest integration path. Importing it registers profiling handlers on the default serve mux. For production servers, you should bind these endpoints to a separate, internal-only port to prevent unauthorized access.
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"time"
)
func main() {
// Start pprof server on a separate port for internal access only
go func() {
log.Println("pprof server listening on :6060")
log.Fatal(http.ListenAndServe("localhost:6060", nil))
}()
// Your main application server
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func handler(w http.ResponseWriter, r *http.Request) {
// Simulate some work
time.Sleep(10 * time.Millisecond)
w.Write([]byte("OK"))
}
With this setup, you can access profiling data at http://localhost:6060/debug/pprof/. The endpoints available include:
/debug/pprof/profile— 30-second CPU profile/debug/pprof/heap— current heap snapshot/debug/pprof/goroutine— all goroutine stacks/debug/pprof/block— blocking profile/debug/pprof/mutex— mutex contention profile
Capturing and Analyzing CPU Profiles
A CPU profile shows where your server spends its time while actively executing code. It does not capture time spent waiting for I/O or sleeping. To collect a CPU profile while the server is under load, use the pprof HTTP endpoint or the go tool pprof command directly.
Collecting a Profile via HTTP
# Fetch a 30-second CPU profile and open the visualization
curl -o cpu.prof http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof -http=:8081 cpu.prof
The -http flag opens an interactive web interface with flame graphs, call graphs, and source-level annotations. Flame graphs are particularly powerful: each horizontal bar represents a function, with width proportional to CPU time. You can click to drill down into callers and callees.
Programmatic Profiling
For targeted profiling of specific code paths—such as a particular request handler during a benchmark—use runtime/pprof directly:
package main
import (
"os"
"runtime/pprof"
)
func profileHandler() {
f, err := os.Create("cpu.prof")
if err != nil {
panic(err)
}
defer f.Close()
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// Execute the code you want to profile here
expensiveOperation()
}
func expensiveOperation() {
// CPU-intensive work
sum := 0
for i := 0; i < 1_000_000; i++ {
sum += i * i
}
}
Heap Profiling: Diagnosing Memory Issues
Heap profiles reveal memory allocation patterns. Go's garbage collector works hard to keep allocations minimal, but excessive allocations still cause GC pressure, increased latency, and eventual OOM errors. The heap profile shows both the number of allocations and total bytes allocated, per call site.
Collecting Heap Snapshots
# Download current heap profile
curl -o heap.prof http://localhost:6060/debug/pprof/heap
go tool pprof -http=:8081 heap.prof
Within pprof, use the top command to see the biggest allocators:
(pprof) top
Showing nodes accounting for 512.68MB, 94.21% of 544.12MB total
flat flat% sum% cum cum%
256.34MB 47.11% 47.11% 256.34MB 47.11% bytes.NewBuffer
128.17MB 23.55% 70.66% 128.17MB 23.55% json.Marshal
64.08MB 11.78% 82.44% 64.08MB 11.78% io.Copy
32.04MB 5.89% 88.33% 32.04MB 5.89% fmt.Sprintf
The flat column shows memory allocated directly by the function. The cum column includes allocations from functions it called. High flat values indicate functions that allocate heavily themselves; high cum values point to call chains that ultimately cause large allocations.
Common Allocation Hotspots
Frequent allocation sources in Go servers include:
- JSON serialization:
json.Marshalallocates temporary buffers and reflection objects. Consider usingjson.Encoderwith pre-allocated writers or switching to faster libraries likegithub.com/goccy/go-json - String concatenation in loops: Each
+operation creates a new string. Usestrings.Builderinstead - Slice growth: Appending to slices without pre-allocation causes repeated reallocations. Always use
make([]T, 0, expectedSize)when the final size is known - Context.WithValue: Creates a new context wrapping the parent. Minimize its use in hot paths
Fixing Allocation-Heavy Code
// Before: heavy allocations
func buildResponse(records []Record) string {
result := ""
for _, r := range records {
result += r.String() // allocates new string each iteration
}
return result
}
// After: single allocation
func buildResponseOptimized(records []Record) string {
var builder strings.Builder
// Pre-allocate based on expected size
builder.Grow(len(records) * 64)
for _, r := range records {
builder.WriteString(r.String())
}
return builder.String()
}
Goroutine Profiling: Finding Leaks and Stalls
Goroutines are lightweight, but a server with thousands of leaked or blocked goroutines consumes memory and eventually stalls. The goroutine profile captures the full stack trace of every goroutine, making it trivial to spot leaks.
# Fetch goroutine profile
curl -o goroutine.prof http://localhost:6060/debug/pprof/goroutine
go tool pprof -http=:8081 goroutine.prof
In the pprof interface, goroutines are grouped by their stack traces. You'll see entries like:
1000 goroutines @ 0x43a1b0 0x45c8a0
# 0x45c8a0 net/http.(*connReader).Read+0x1a0
# 0x43a1b0 net/http.(*conn).readRequest+0x1b0
If you see hundreds of goroutines blocked on chan receive or select with no corresponding sender, you have a leak. The fix is ensuring every channel send has a guaranteed receive, typically via context cancellation or timeout patterns.
Detecting Goroutine Leaks Programmatically
package main
import (
"fmt"
"runtime"
"time"
)
func monitorGoroutines() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
count := runtime.NumGoroutine()
fmt.Printf("goroutines: %d\n", count)
if count > 1000 {
// Trigger alert or dump stacks
buf := make([]byte, 1<<20)
runtime.Stack(buf, true)
fmt.Println(string(buf))
}
}
}
Block and Mutex Profiling
Block profiling measures time spent waiting on synchronization primitives. It helps identify where goroutines spend time waiting for mutexes or channel operations. Mutex profiling specifically targets lock contention. Both are disabled by default due to overhead; enable them explicitly:
package main
import (
"os"
"runtime"
"runtime/pprof"
)
func main() {
// Enable block profiling
runtime.SetBlockProfileRate(1)
// Enable mutex profiling with fraction 1 (profile every contention)
runtime.SetMutexProfileFraction(1)
// Later, write profiles
f, _ := os.Create("block.prof")
pprof.Lookup("block").WriteTo(f, 0)
f.Close()
f2, _ := os.Create("mutex.prof")
pprof.Lookup("mutex").WriteTo(f2, 0)
f2.Close()
}
High mutex contention often stems from overly broad critical sections or hot locks. Solutions include reducing lock scope, using read/write mutexes (sync.RWMutex), or adopting lock-free data structures like sync.Map for read-heavy workloads.
Go Tool Trace: Timing and Latency Analysis
While pprof shows aggregate resource consumption, the Go execution tracer captures precise timing of events—goroutine scheduling, network polling, garbage collection, and syscalls. This is invaluable for diagnosing latency spikes and scheduler delays.
package main
import (
"net/http"
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// Run your server workload
http.ListenAndServe(":8080", nil)
}
Visualize the trace with:
go tool trace trace.out
The trace viewer shows a timeline of goroutine states per logical processor. You can see exactly when a goroutine is running, waiting, or blocked. Use it to answer questions like: "Why did this request take 500ms when the handler only does 10ms of CPU work?" The answer often lies in goroutine scheduling delays, GC pauses, or network contention.
Benchmark-Driven Optimization
Profiling identifies hotspots; benchmarks measure the impact of your fixes. Always establish a baseline before optimizing. Go's testing package includes robust benchmarking support:
package main
import (
"strings"
"testing"
)
func BenchmarkBuildResponse(b *testing.B) {
records := make([]Record, 100)
for i := 0; i < len(records); i++ {
records[i] = Record{ID: i, Name: "example"}
}
b.ResetTimer() // exclude setup time
for i := 0; i < b.N; i++ {
buildResponse(records)
}
}
func BenchmarkBuildResponseOptimized(b *testing.B) {
records := make([]Record, 100)
for i := 0; i < len(records); i++ {
records[i] = Record{ID: i, Name: "example"}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
buildResponseOptimized(records)
}
}
Run benchmarks with memory allocation tracking:
go test -bench=. -benchmem -cpuprofile=cpu.prof -memprofile=mem.prof
The -benchmem flag reports allocations per operation, crucial for verifying that your optimization actually reduced allocations.
Common Optimization Patterns
1. Object Pooling with sync.Pool
For frequently allocated short-lived objects, sync.Pool recycles instances to reduce GC pressure:
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func handler(w http.ResponseWriter, r *http.Request) {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
buf.WriteString("processing request")
// use buf...
}
2. Pre-allocating Slices and Maps
// Before: multiple allocations as slice grows
func collectIDs(items []Item) []int {
var ids []int
for _, item := range items {
ids = append(ids, item.ID)
}
return ids
}
// After: single allocation
func collectIDsOptimized(items []Item) []int {
ids := make([]int, 0, len(items))
for _, item := range items {
ids = append(ids, item.ID)
}
return ids
}
3. String Building with strings.Builder
func formatList(items []string) string {
var b strings.Builder
b.Grow(len(items) * 20) // estimate average string length
for i, s := range items {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(s)
}
return b.String()
}
4. Avoiding Reflection-Heavy Libraries in Hot Paths
Standard encoding/json uses reflection extensively. For high-throughput servers, consider code-generation based alternatives:
// Using github.com/goccy/go-json (drop-in replacement, faster)
import "github.com/goccy/go-json"
// Or use protocol buffers/gRPC for internal services
// Or consider easyjson for code generation
5. Connection Pooling and Reuse
Don't create a new HTTP client for each outgoing request. Reuse a shared client with tuned transport settings:
var httpClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
DisableCompression: false,
},
}
func proxyHandler(w http.ResponseWriter, r *http.Request) {
// Reuse httpClient across all requests
resp, err := httpClient.Get("http://backend/api")
// handle response
}
Continuous Profiling in Production
For long-running production servers, periodic profiling captures issues that emerge over days or weeks. Rather than manual collection, integrate profiling into your observability stack:
package main
import (
"log"
"net/http"
"os"
"runtime/pprof"
"time"
)
func periodicProfiling() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
// Heap snapshot
f, err := os.Create("heap_" + time.Now().Format("150405") + ".prof")
if err == nil {
pprof.WriteHeapProfile(f)
f.Close()
}
// Goroutine snapshot
f2, err := os.Create("goroutine_" + time.Now().Format("150405") + ".prof")
if err == nil {
pprof.Lookup("goroutine").WriteTo(f2, 0)
f2.Close()
}
}
}
Combine this with a monitoring system that alerts when goroutine counts or heap sizes exceed thresholds.
Best Practices for Go Server Profiling
- Profile under realistic load: Development profiles with a single request reveal nothing. Use production traffic replay or load testing tools like
vegeta,hey, ork6to generate representative workloads before profiling - Profile before optimizing: Never optimize based on code review alone. A function that looks inefficient may be rarely called; a simple line may hide an expensive operation
- Use multiple profile types: CPU hotspots don't explain memory leaks. Goroutine profiles don't show lock contention. Use all profile types together for a complete picture
- Separate profiling endpoints: Bind pprof to a different port, ideally on localhost or behind an authenticated reverse proxy. Never expose profiling endpoints publicly
- Benchmark before and after: Every optimization must be measured. A "fast" version that allocates less might actually be slower due to different code paths
- Watch the cumulative column: In pprof,
cum(cumulative) often matters more thanflat. A function that allocates nothing but calls a function that allocates 100MB is your real target - Optimize the hottest path first: Fixing a 40% CPU consumer yields more benefit than optimizing ten 1% consumers. Use the 80/20 rule
- Keep profiles for comparison: Store profiles alongside deployment versions. When latency regresses, diff old and new profiles to pinpoint the change
- Set profiling rates carefully:
runtime.SetBlockProfileRateandSetMutexProfileFractionadd overhead. Use values like 1-1000 in production; higher values collect less data but reduce performance impact - Integrate tracing for latency issues: When pprof shows adequate CPU but users report high latency, switch to
runtime/traceto find scheduling delays, GC pauses, or network contention
Putting It All Together: A Complete Profiling Workflow
Here's a realistic optimization scenario for a Go HTTP server that handles JSON API requests:
package main
import (
"encoding/json"
"log"
"net/http"
_ "net/http/pprof"
"sync"
"time"
)
type Request struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
type Response struct {
Results []string `json:"results"`
Count int `json:"count"`
}
var (
// Pre-allocated buffer pool for responses
bufPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 4096)
},
}
)
func main() {
go func() {
log.Fatal(http.ListenAndServe("localhost:6060", nil))
}()
http.HandleFunc("/api/search", searchHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
var req Request
// Use a decoder that reuses the reader, avoiding extra allocations
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&req); err != nil {
http.Error(w, "bad request", 400)
return
}
// Simulate search
results := make([]string, 0, req.Limit)
for i := 0; i < req.Limit; i++ {
results = append(results, "result_"+string(rune(i)))
}
resp := Response{
Results: results,
Count: len(results),
}
// Use pooled buffer for encoding
buf := bufPool.Get().([]byte)
buf = buf[:0]
defer bufPool.Put(buf)
// Encode directly to the buffer
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false) // avoid escaping overhead for API responses
enc.Encode(resp)
}
Step 1: Establish Baseline
# Run load test
hey -n 100000 -c 100 http://localhost:8080/api/search
# While load test runs, capture profiles
curl -o cpu.prof "http://localhost:6060/debug/pprof/profile?seconds=30"
curl -o heap.prof http://localhost:6060/debug/pprof/heap
curl -o goroutine.prof http://localhost:6060/debug/pprof/goroutine
Step 2: Analyze
go tool pprof -http=:8081 cpu.prof
# Look at flame graph, identify top consumers
# Check heap.prof for allocation hotspots
# Verify goroutine count is stable
Step 3: Optimize and Verify
# After changes, run benchmarks
go test -bench=. -benchmem -count=5
# Compare before/after profiles
go tool pprof -diff_base=before.prof after.prof
The -diff_base flag shows exactly which functions changed their resource consumption between profiles.
Conclusion
Go's profiling and tracing tools form one of the most comprehensive performance analysis suites in any compiled language runtime. By integrating pprof endpoints into your server, capturing profiles under realistic load, and systematically addressing the hotspots revealed in flame graphs and allocation tables, you can transform a sluggish server into one that handles thousands of requests per second with minimal resource consumption. The key insight is that profiling replaces intuition with evidence. Rather than guessing which code is slow, you measure it. Rather than assuming an optimization works, you benchmark it. This data-driven approach, combined with Go's zero-cost profiling instrumentation and rich visualization tools, makes performance optimization a repeatable, reliable engineering discipline rather than an arcane art. Start with the CPU and heap profiles—they solve 80% of performance problems—then reach for goroutine and mutex profiling when you encounter stalls or leaks. Make profiling a routine part of your development cycle, and your Go servers will consistently deliver the performance your users expect.