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:
- CPU Profile — Shows which functions consume the most CPU time and how often they are called. This is your primary tool for finding hot loops and expensive computations.
- Heap Profile (Memory) — Reveals where allocations occur, how many bytes are allocated, and which functions retain the most live heap memory. Essential for reducing garbage collection pressure.
- Goroutine Profile — Lists all currently active goroutines with their stack traces. Invaluable for detecting goroutine leaks and understanding concurrency patterns.
- Block Profile — Captures where goroutines block on synchronization primitives like mutexes, channels, and I/O. Helps identify contention points.
- Mutex Profile — Specifically tracks lock contention, showing which mutexes are held longest and where goroutines spend time waiting to acquire locks.
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:
runtime/pprof— The low-level profiling API. Use this for one-shot profile collection in non-HTTP applications or when you need fine-grained control.net/http/pprof— Automatically registers profiling endpoints on an HTTP server. This is the go-to choice for long-running API services where you want on-demand profile snapshots without restarting the process.go tool pprof— The command-line analysis tool that visualizes and interrogates profile data. It supports both text-based inspection and graphical flame graphs via a built-in web interface.
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
- Profile Under Realistic Load — Profiles captured at idle or with synthetic traffic may miss contention points that only appear under concurrency. Always profile with production-like request patterns.
- Collect Profiles Over Adequate Duration — For CPU profiles, 30–60 seconds is typical. Too short and you capture noise; too long and the profile becomes unwieldy.
- Secure Profiling Endpoints — The
/debug/pprofendpoints expose internal state. Never expose them to the public internet. Bind them to a private network interface, use a separate port, or add authentication middleware. - Use Continuous Profiling in Production — Tools like Google's
golang.org/x/exp/eventor third-party services (Datadog, Pyroscope) can capture profiles continuously with minimal overhead, giving you a time-series view of performance changes after deployments. - Profile Memory with Multiple Snapshots — A single heap profile shows a point-in-time view. Take snapshots before, during, and after load tests to see how memory evolves. The
alloc_spacevsinuse_spacedistinction matters: high allocation rates may not show as high in-use memory if GC is efficient. - Enable Block and Mutex Profiles Selectively — These profiles have runtime overhead (they instrument synchronization operations). Set them explicitly when investigating contention, and disable them for routine profiling:
// Enable mutex profiling for contention investigation
runtime.SetMutexProfileFraction(5) // Record 1 in 5 mutex events
runtime.SetBlockProfileRate(10000) // Record blocking events >= 10µs
- Combine Profiles for Holistic Understanding — A CPU profile might show high time in GC pauses; cross-reference with a heap profile to find the allocation-heavy code triggering those pauses. A goroutine profile showing thousands of blocked goroutines pairs with a block profile to identify the contention source.
- Profile Before and After Every Optimization — Never assume an optimization worked. Always capture before/after profiles and benchmarks to confirm improvement and ensure no regression in other areas.
- Document Profile Baselines — Keep a record of profile summaries for key endpoints. When latency spikes after a deployment, compare current profiles against baselines to rapidly identify what changed.
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.