← Back to DevBytes

Go Server Performance: Profiling and Optimization

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:

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:

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:

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles