Go performance tips: a practical guide for developers
Most Go services run fast enough until they don't. We've all shipped code that worked fine at 100 requests per second, then watched latency spike when traffic climbed to 10,000. The good news: Go's runtime gives us excellent visibility into what's actually slow, and the fixes are usually straightforward once you know where to look.
This guide covers the performance wins we've seen repeatedly in production services—from HTTP clients to database batching. These aren't theoretical optimizations; they're the moves that shaved milliseconds off our 95th percentile latencies and kept our infrastructure costs reasonable.
Reuse HTTP clients and buffers
Creating a new HTTP client for each request is expensive. The http.Client maintains a connection pool, and when you discard it, you lose that pool. We've seen teams naively create a fresh client in hot loops, forcing the runtime to establish new TCP connections repeatedly.
The pattern is simple: initialize your HTTP client once at startup and reuse it globally or pass it as a dependency.
// Bad: connection pool wasted
func FetchUser(userID string) (*User, error) {
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get("https://api.example.com/users/" + userID)
// ...
}
// Good: connection pool reused
var httpClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
func FetchUser(userID string) (*User, error) {
resp, err := httpClient.Get("https://api.example.com/users/" + userID)
// ...
}The same principle applies to buffers. Use sync.Pool to recycle byte slices and writers, especially in request handlers that run thousands of times per second.
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()
// Use buf for temporary work
json.NewEncoder(buf).Encode(data)
w.Write(buf.Bytes())
}In one e-commerce service, switching from per-request clients to a shared pool reduced our average response time by 40ms and dropped GC pressure noticeably. The change took an hour and had no downside.
Avoid reflection in hot paths
Reflection is powerful but slow. When you call reflect.ValueOf, reflect.TypeOf, or iterate through struct fields at runtime, you're paying a real cost. If that code runs thousands of times per second, the cost compounds quickly.
The trap is usually in generic serialization or validation layers. For example, a naive handler that uses reflection to validate every incoming request:
// Bad: reflection on every request
func ValidateRequest(v interface{}) error {
rv := reflect.ValueOf(v)
for i := 0; i < rv.NumField(); i++ {
field := rv.Field(i)
if field.Kind() == reflect.String && field.String() == "" {
return errors.New("empty field")
}
}
return nil
}
// Good: type-specific validation
type CreateUserReq struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (r *CreateUserReq) Validate() error {
if r.Name == "" || r.Email == "" {
return errors.New("name and email required")
}
return nil
}When you must use reflection—say, in a data mapper or ORM—isolate it. Pre-compute reflected information at startup or cache it aggressively. Many teams have good success with code generation: let a tool build reflection-free serializers for your types once, then deploy the generated code.
We benchmarked a CRM service's contact serialization: the reflection-based version took 15 microseconds per contact, while hand-written JSON marshaling took 1.2 microseconds. At scale, that difference matters.
Batch database calls
N+1 queries haunt Go services as much as any language. The problem worsens when your database connection pool is small relative to goroutine count. Each goroutine might sit blocked waiting for a connection while queries pile up.
The solution is batch loading: collect requests, fetch them together, then distribute results back. This trades a bit of latency per individual request for much better throughput.
// Bad: N queries, each blocking separately
func GetUserOrders(userID string) ([]Order, error) {
var orders []Order
rows, err := db.Query("SELECT id FROM orders WHERE user_id = $1", userID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var orderID string
rows.Scan(&orderID)
// Another query per order
order, err := db.QueryRow("SELECT * FROM orders WHERE id = $1", orderID).Scan(&order)
// ...
}
return orders, nil
}
// Good: batch load
func GetUserOrders(userID string) ([]Order, error) {
rows, err := db.Query(
`SELECT id, status, total FROM orders WHERE user_id = $1 ORDER BY created_at DESC`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var orders []Order
for rows.Next() {
var o Order
rows.Scan(&o.ID, &o.Status, &o.Total)
orders = append(orders, o)
}
return orders, nil
}For more complex scenarios, implement a data loader pattern. Collect IDs from multiple goroutines, fire a single query with IN, then route results back:
type UserLoader struct {
mu sync.Mutex
pending map[string]chan *User
}
func (ul *UserLoader) Get(userID string) *User {
ul.mu.Lock()
ch := make(chan *User, 1)
ul.pending[userID] = ch
ul.mu.Unlock()
// Fire batch query after a short window
return <-ch
}In a trading platform, switching from per-trade lookups to batched position queries reduced database load by 70% and cut p95 latency in half, even though individual query latency was slightly higher.
Profile with pprof first
Before optimizing anything, measure. Go's built-in pprof profiler is fast and accurate. Use it to find where your service actually spends time.
Start by importing the profiler and exposing its HTTP endpoints:
import _ "net/http/pprof"
func init() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
}Then run a load test and capture a profile:
// Run for 30 seconds
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// In pprof: top to see cumulative time
// list FunctionName to see line-by-line costs
// web to generate a flamegraph (requires graphviz)Memory profiles are equally valuable:
go tool pprof http://localhost:6060/debug/pprof/heap
// Identify allocations in hot pathsThe typical workflow: identify the top 3 functions consuming CPU or memory, understand why they're expensive, apply targeted fixes, then profile again. Guessing rarely works. A ride-hailing service we worked with spent two weeks optimizing marshaling code before profiling revealed the real bottleneck was their context-checking middleware, which was running reflection on every request.
Common mistakes
- Premature optimization: Profile first. The function that looks slow might not be. Spend effort where pprof says the time actually goes.
- Ignoring GC pressure: High allocation rates force frequent garbage collection. Reusing buffers and preallocating slices where size is known is often more impactful than micro-optimizations.
- Tuning without a baseline: Always measure before and after. A change that felt right might have no effect or make things worse at scale.
- Assuming connection pooling is automatic: Many libraries require explicit configuration. Read the docs. An undersized connection pool can become your limiting factor.
- Context timeouts set too aggressively: Tight timeouts prevent legitimate work from completing and increase retry load. Profile latency distributions before setting them.
Key takeaways
- Reuse HTTP clients and recycle buffers via
sync.Poolto avoid connection and allocation overhead in hot paths. - Move reflection out of request handlers and into startup or code-generation phases; hand-written serializers typically outpace generic reflection by 10x.
- Batch database queries to reduce connection pool contention and network round-trips; prefer a few multi-row queries over many single-row queries.
- Profile with
pprofbefore optimizing; cumulative CPU time and heap profiles reveal where your service really spends resources. - Test optimizations end-to-end under realistic load; surprises are common and measurement is your defense against guessing wrong.






Comments
Loading comments…