← Back to DevBytes

Go API Performance: Profiling and Optimization

Understanding API Performance Profiling in Go

Profiling is the process of measuring and analyzing an application's runtime behavior to identify performance bottlenecks. In the context of Go APIs, profiling reveals where your code spends its time, how memory is allocated, and how goroutines behave under load. Unlike simple benchmarking—which tells you how fast something runs—profiling tells you why it runs at that speed by providing a detailed breakdown of function-level resource consumption.

Go offers several profile types, each illuminating a different dimension of performance:

Why Profiling Matters for Go APIs

A slow API endpoint degrades user experience, increases infrastructure costs, and can cause cascading failures in microservice architectures. Without profiling, developers often resort to guesswork—randomly optimizing code that "looks slow" while missing the real bottlenecks. Profiling provides evidence-driven optimization: you fix what actually matters, not what you suspect matters. For production APIs handling thousands of requests per second, a 5% reduction in CPU usage can translate to significant cloud cost savings and improved tail latency.

Built-in Profiling Tools in Go

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Go's standard library includes a remarkably capable profiling toolkit. The three pillars are:

Setting Up Profiling in Your Go API

Adding profiling endpoints to an existing Go API is straightforward. Import the net/http/pprof package and attach its handlers to your router. The following example shows a complete API server with profiling enabled on a separate port—a common pattern that keeps profiling endpoints isolated from public traffic.

package main

import (
    "fmt"
    "log"
    "net/http"
    _ "net/http/pprof" // Blank import registers handlers on http.DefaultServeMux
    "time"
)

// simulateWork does some CPU-bound computation to give the profiler something to see.
func simulateWork() {
    // Allocate a slice and perform a bubble-sort-like operation
    data := make([]int, 1000)
    for i := range data {
        data[i] = 1000 - i
    }
    // Inefficient bubble sort for demonstration purposes
    for i := 0; i < len(data); i++ {
        for j := 0; j < len(data)-i-1; j++ {
            if data[j] > data[j+1] {
                data[j], data[j+1] = data[j+1], data[j]
            }
        }
    }
}

// apiHandler is a regular API endpoint handler.
func apiHandler(w http.ResponseWriter, r *http.Request) {
    simulateWork()
    fmt.Fprintf(w, `{"status":"ok","message":"processed"}`)
}

func main() {
    // Start the profiling server on a separate port (e.g., 6060)
    // The blank import of net/http/pprof registers handlers like:
    //   /debug/pprof/
    //   /debug/pprof/cmdline
    //   /debug/pprof/profile  (CPU profile)
    //   /debug/pprof/heap     (memory profile)
    //   /debug/pprof/goroutine
    //   /debug/pprof/block
    //   /debug/pprof/mutex
    go func() {
        log.Println("Starting pprof server on :6060")
        log.Fatal(http.ListenAndServe(":6060", nil))
    }()

    // Your main API mux
    mux := http.NewServeMux()
    mux.HandleFunc("/api/work", apiHandler)

    log.Println("Starting API server on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

With this server running, you can collect profiles via HTTP endpoints. For a CPU profile, visit /debug/pprof/profile?seconds=30 to capture 30 seconds of CPU activity. The server will block during collection and return the profile as a binary file that you can analyze with go tool pprof.

Collecting and Analyzing CPU Profiles

CPU profiling is the most common starting point. It answers the question: "Which functions consume the most CPU cycles?" Here is a practical workflow for collecting and analyzing a CPU profile from a running API.

Step 1: Generate Load on Your API

While the profile collects data, you need realistic traffic hitting your endpoints. Use a tool like hey, wrk, or vegeta to generate sustained requests:

# In one terminal, run your Go server
go run main.go

# In another terminal, generate load for 60 seconds
hey -z 60s -c 50 http://localhost:8080/api/work

Step 2: Capture the CPU Profile

# Download a 30-second CPU profile while load is active
curl -o cpu_profile.pb.gz "http://localhost:6060/debug/pprof/profile?seconds=30"

Step 3: Analyze with go tool pprof

The go tool pprof command opens an interactive shell for exploring the profile. You can run it in several modes:

# Interactive text mode
go tool pprof cpu_profile.pb.gz

# Common commands inside pprof interactive mode:
#   top          - shows top 10 functions by CPU consumption
#   top20        - shows top 20
#   list funcName - shows source code annotated with CPU time per line
#   web          - opens a graphical flame graph in your browser
#   peek funcName - shows callers and callees of a function

# Alternative: launch the web interface directly
go tool pprof -http=:8081 cpu_profile.pb.gz

The top command output looks something like this:

Showing nodes accounting for 12.34s, 95.67% of 12.90s total
      flat  flat%   sum%        cum   cum%
     5.21s 40.39% 40.39%      9.87s 76.51%  main.simulateWork
     3.45s 26.74% 67.13%      3.45s 26.74%  runtime.memmove
     1.89s 14.65% 81.78%      1.89s 14.65%  syscall.Syscall
     ...

The flat column shows time spent directly inside a function. The cumulative (cum) column includes time spent in functions that this function calls. If flat and cum differ significantly, the function delegates work to its callees.

Memory Profiling and Heap Optimization

Memory profiling reveals allocation patterns. Excessive allocations increase garbage collector (GC) frequency and pause times, directly impacting API latency percentiles. The heap profile shows both allocations (total bytes allocated since process start) and in-use (live objects currently on the heap).

Capturing a Heap Profile

# Capture current heap snapshot
curl -o heap_profile.pb.gz "http://localhost:6060/debug/pprof/heap"

# Analyze
go tool pprof heap_profile.pb.gz

# In pprof, try:
#   top         - top allocators
#   list funcName - where allocations happen in source
#   inuse_space - switch to in-use memory view
#   alloc_space - switch to total allocation view

Example: Finding and Fixing Allocation Hotspots

Consider this handler that builds a JSON response inefficiently:

// BEFORE: Heavy allocations from string concatenation in a loop
func searchHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    results := fetchResults(query) // returns []string with 1000 items

    // BAD: string concatenation in a loop creates many intermediate strings
    var response string
    for _, item := range results {
        response += item + "\n" // Each iteration allocates a new string
    }
    w.Write([]byte(response))
}

A heap profile would flag this handler with high allocation rates. Here is the optimized version:

// AFTER: Use strings.Builder to minimize allocations
func searchHandler(w http.ResponseWriter, r *http.Request) {
    query := r.URL.Query().Get("q")
    results := fetchResults(query)

    // GOOD: strings.Builder pre-allocates and grows efficiently
    var builder strings.Builder
    builder.Grow(len(results) * 50) // Estimate average line length
    for _, item := range results {
        builder.WriteString(item)
        builder.WriteByte('\n')
    }
    w.Write([]byte(builder.String()))
}

Using sync.Pool to Reuse Objects

For APIs that create many short-lived objects of the same type, sync.Pool dramatically reduces allocation pressure:

import "sync"

// Create a pool of reusable buffers
var bufferPool = sync.Pool{
    New: func() interface{} {
        // Only called when the pool is empty
        return make([]byte, 0, 4096)
    },
}

func handlerWithPool(w http.ResponseWriter, r *http.Request) {
    // Get a buffer from the pool (or allocate a new one if pool is empty)
    buf := bufferPool.Get().([]byte)
    defer func() {
        // Reset and return to pool after use
        buf = buf[:0]
        bufferPool.Put(buf)
    }()

    // Use buf for building the response...
    buf = append(buf, "processed data"...)
    w.Write(buf)
}

Goroutine Profiling and Concurrency Optimization

A goroutine profile shows every goroutine's stack trace at a moment in time. This is critical for detecting goroutine leaks—goroutines that block indefinitely and never terminate, gradually consuming memory and eventually crashing the process.

Capturing a Goroutine Profile

# Capture current goroutine state
curl -o goroutine_profile.pb.gz "http://localhost:6060/debug/pprof/goroutine?debug=2"

# The debug=2 parameter returns a human-readable text format
# showing all goroutine stacks, which is often more useful than the binary format
curl "http://localhost:6060/debug/pprof/goroutine?debug=2"

Look for goroutines stuck in chan receive, select, or IO wait states. A healthy API should have a stable goroutine count under steady load. If the count grows monotonically, you have a leak.

Example: Fixing a Goroutine Leak

// LEAKY: Goroutine never exits because the channel is never closed
func processBatch(items []string) {
    results := make(chan string)
    for _, item := range items {
        go func(item string) {
            // This goroutine blocks forever if the caller stops reading results
            results <- heavyProcessing(item)
        }(item)
    }
    // If an error occurs partway through, we return early,
    // leaving goroutines blocked on results <- ...
    for r := range results {
        fmt.Println(r)
    }
}

// FIXED: Use a done channel or context for cancellation
func processBatchFixed(items []string) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    results := make(chan string, len(items)) // Buffered channel prevents blocking
    for _, item := range items {
        go func(item string) {
            select {
            case results <- heavyProcessing(item):
            case <-ctx.Done():
                return // Goroutine exits cleanly on timeout
            }
        }(item)
    }
    for i := 0; i < len(items); i++ {
        select {
        case r := <-results:
            fmt.Println(r)
        case <-ctx.Done():
            fmt.Println("timeout, abandoning remaining results")
            return
        }
    }
}

Benchmarking Your API Endpoints

Profiling identifies bottlenecks; benchmarking quantifies the impact of your optimizations. Go's testing package supports benchmark functions that measure execution time and allocations under controlled conditions.

Writing a Benchmark

package main

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

// Benchmark the search handler
func BenchmarkSearchHandler(b *testing.B) {
    // Create a test request
    req := httptest.NewRequest("GET", "/api/search?q=golang", nil)
    w := httptest.NewRecorder()

    b.ResetTimer() // Reset timer after setup
    for i := 0; i < b.N; i++ {
        searchHandler(w, req)
    }
}

// Run with: go test -bench=. -benchmem -cpuprofile=cpu.out -memprofile=mem.out
// -benchmem shows allocation statistics per iteration
// -cpuprofile and -memprofile generate profiles from the benchmark run

Comparing Before and After

Go provides benchstat (from golang.org/x/perf/cmd/benchstat) for statistical comparison of benchmark results:

# Run benchmarks before changes and save results
go test -bench=. -count=10 > old.txt

# Make your optimizations, then run again
go test -bench=. -count=10 > new.txt

# Compare with statistical confidence
benchstat old.txt new.txt

# Output example:
# name              old time/op    new time/op    delta
# SearchHandler-8     2.45ms ± 2%    1.12ms ± 3%  -54.29%  (p=0.000)
# name              old alloc/op   new alloc/op   delta
# SearchHandler-8     48.2kB ± 0%    12.1kB ± 0%  -74.90%  (p=0.000)

Common Optimization Patterns for Go APIs

1. Connection Pooling and HTTP Client Reuse

Creating a new http.Client for each outgoing request is expensive. The client's transport contains a connection pool that should be shared across requests:

// BAD: Creates a new transport each time
func callExternalAPI(url string) (*http.Response, error) {
    client := &http.Client{
        Timeout: 10 * time.Second,
    }
    return client.Get(url)
}

// GOOD: Reuse a shared client with tuned connection pool
var sharedClient = &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        IdleConnTimeout:     90 * time.Second,
        DisableCompression:  false,
    },
}

func callExternalAPI(url string) (*http.Response, error) {
    return sharedClient.Get(url)
}

2. JSON Encoding Without Reflection Overhead

The standard encoding/json package uses reflection, which is slower than code-generated alternatives. For high-throughput APIs, consider using github.com/goccy/go-json or github.com/bytedance/sonic (both drop-in replacements with significantly better performance):

// Using a faster JSON library (example with sonic)
import "github.com/bytedance/sonic"

func handler(w http.ResponseWriter, r *http.Request) {
    data := map[string]interface{}{
        "id":   123,
        "name": "example",
    }
    // sonic.Encode is up to 3x faster than encoding/json
    sonic.ConfigDefault.NewEncoder(w).Encode(data)
}

3. Pre-allocating Slices

Appending to slices without capacity hints causes repeated allocations as the backing array grows. Always pre-allocate when you know the approximate size:

// BAD: Multiple allocations as slice grows
func collectItems(limit int) []int {
    var items []int
    for i := 0; i < limit; i++ {
        items = append(items, i)
    }
    return items
}

// GOOD: Single allocation upfront
func collectItems(limit int) []int {
    items := make([]int, 0, limit) // capacity hint
    for i := 0; i < limit; i++ {
        items = append(items, i)
    }
    return items
}

4. Avoiding Unnecessary Pointer Indirects

Go's garbage collector has to scan pointers. Large slices of pointers create more GC work than slices of values:

// HEAVIER GC: Slice of pointers
type Record struct {
    ID   int
    Data [256]byte
}
records := make([]*Record, 10000) // GC must scan 10000 pointers

// LIGHTER GC: Slice of values (contiguous memory, fewer pointers)
records := make([]Record, 10000) // One contiguous block, no pointer scanning

5. Structured Logging with Sampling

Logging every request at high volume is a hidden performance killer. Use structured logging with sampling:

import "log/slog"

// Create a handler that samples debug logs
var apiLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo, // Skip debug in production
}))

Best Practices for Production Profiling

// Enable mutex profiling for contention investigation
runtime.SetMutexProfileFraction(5) // Record 1 in 5 mutex events
runtime.SetBlockProfileRate(10000) // Record blocking events >= 10µs

Conclusion

Go's profiling ecosystem—spanning pprof endpoints, benchmark tooling, and visualization utilities—transforms performance optimization from guesswork into a disciplined, data-driven process. By integrating CPU, heap, goroutine, block, and mutex profiles into your development workflow, you gain a comprehensive view of your API's runtime behavior. Start with CPU and heap profiles to address the most common bottlenecks, then dive into goroutine and block profiles when concurrency issues surface. Profile continuously, benchmark rigorously, and let the data guide every optimization decision. The result is an API that not only serves users faster but does so with predictable, cost-efficient resource consumption.

🚀 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