Skip to content
Performance

Performance and optimization in Go: a practical guide 🏎️

Optimizing performance in Go means working on several fronts: memory, concurrency, data structures, profiling, and architectural choices. This guide combines best practices, practical examples, and advice drawn from real-world experience.

Memory management 🧠

Stack vs heap

Optimizing memory management in Go starts with understanding where variables are allocated. Local variables stay on the stack, which is very fast, while those that escape the scope end up on the heap, which is slower and managed by the garbage collector. To understand where your variables land, you can use escape analysis:

1
go build -gcflags=-m main.go

For example:

1
2
3
4
func newInt() *int {
    x := 42
    return &x // x is allocated on the heap because it escapes the function
}

When possible, return values instead of pointers to avoid unnecessary heap allocations.

Struct alignment

Struct alignment in Go is often overlooked, but it is fundamental for getting the most performance, especially in programs that use data structures intensively or handle large amounts of objects in memory.

When the compiler allocates a struct, it inserts padding between fields to satisfy CPU alignment requirements. That padding helps ensure every field is placed in memory efficiently for processor access, but it can waste memory if fields are not ordered correctly.

Practical rule: order fields from largest to smallest (in bytes). This reduces padding and improves cache locality, making data access faster.

Cache locality indicates how well the data used by the program is grouped in memory. When struct fields are aligned and ordered correctly, the processor can load more useful data in a single cache access, reducing wait time. Good cache locality improves performance because it minimizes cache misses, i.e. the situations where the CPU has to fetch data from RAM instead of the much faster cache. In short: well-aligned, compact data structures make better use of the CPU cache, making the program faster.

Example of an optimized struct:

1
2
3
4
5
6
type Optimized struct {
  Number  int64   // 8 bytes
  Flag    bool    // 1 byte
  Small   byte    // 1 byte
  // 6 bytes of padding added by the compiler
}

If you reverse the field order, the padding can increase:

1
2
3
4
5
6
type NotOptimized struct {
  Flag    bool    // 1 byte
  Small   byte    // 1 byte
  // 6 bytes of padding between Small and Number
  Number  int64   // 8 bytes
}

To check the actual size of a struct and the padding introduced, use the unsafe package:

1
2
fmt.Println(unsafe.Sizeof(Optimized{}))
fmt.Println(unsafe.Sizeof(NotOptimized{}))

Additional tips:

  • if you have many booleans or bytes, consider using arrays or bitfields to save memory;
  • alignment matters most for large slices and maps of structs;
  • on 64-bit systems, keep int64 and float64 fields before smaller types.

Good alignment not only reduces memory usage, but can also noticeably improve performance thanks to more efficient CPU cache access.

Avoid unnecessary copies

In Go, avoiding unnecessary data copies is essential for efficient applications in terms of memory and speed. Slices are reference types: when you pass them to a function, Go does not copy the entire sequence, only a small header pointing to the underlying data. That makes slices cheap to pass around.

However, there are situations where you may accidentally trigger expensive copies:

  • returning large structs by value: if a function returns a large struct by value, Go will copy it. Prefer pointers for very large structs or ones containing large arrays;
  • modifying slices without attention: some slice operations, like append on a shared slice, can create a copy of the data if the capacity is exceeded. If multiple goroutines or functions work on the same slice, consider whether you need to copy it explicitly to avoid side effects;
  • converting between types: converting between a string and a byte slice ([]byte(s)) always creates a copy of the data. If you only need to read, work directly with the original slice or string.
1
2
3
func process(data []int) {
  // works directly on the slice
}

Practical example: copy vs reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Efficient slice passing: no copy of the underlying data
func sum(data []int) int {
  total := 0
  for _, value := range data {
    total += value
  }
  return total
}

// Watch out: returning a large struct by value causes a copy
func createLarge() LargeStruct {
  var value LargeStruct
  // ...fill value...
  return value // this copies the entire struct
}

type LargeStruct struct {
  data [1024]int
}

// Correct approach: return a pointer to avoid the copy
func createLargePtr() *LargeStruct {
  var value LargeStruct
  // ...fill value...
  return &value // only the pointer is copied
}

// Conversion that creates a copy
s := "test string"
b := []byte(s) // copies the string's data

// Efficient slicing: no copy of the data
sub := b[2:6]

Choose the right data structure

The choice between slices, maps, and arrays depends on access and mutation needs. Slices are flexible and lightweight, maps provide fast key-based access, and arrays are useful for fixed-size data.

1
2
var numbers = []int{1, 2, 3}
var table = map[string]int{"a": 1}

Watch out for allocations and copies: prefer passing slices and maps by reference.

Avoid unnecessary interface{} use

Overusing interface{} in Go can create inefficiencies: every value assigned to an interface{} variable gets “boxed”, and if it is not already an interface type it gets allocated on the heap, hurting performance and memory predictability. Overusing interface{} also reduces type safety and makes the code less readable and more prone to runtime errors.

Always prefer concrete types or specific interfaces, only when abstraction is genuinely needed. For example, instead of:

1
var x interface{} = 42 // can cause a heap allocation

use the concrete type directly:

1
var x int = 42 // no extra allocation

When to use interface{}:

  • only if you need to handle heterogeneous data or write generic functions that cannot be expressed with Go 1.18+ generics;
  • if you work with APIs that genuinely require type flexibility, but always consider whether you can narrow the interface.

With modern Go, prefer generics for reusable functions and data structures, avoiding the overhead and loss of type safety of interface{}.

Object pooling and preallocation

To reduce allocations and improve performance, use two strategies:

  • preallocation: when you know the maximum size of a slice or map, preallocate its capacity;
  • object pooling: when the program frequently creates and destroys temporary objects, reuse them with sync.Pool.
1
numbers := make([]int, 0, 100) // efficient preallocation
  • object pooling: if the program frequently creates and destroys temporary objects (buffers, large slices, short-lived structs), use a pool to reuse them and reduce pressure on the garbage collector. In Go, the sync.Pool type is designed for this purpose.

Example of using a pool for temporary buffers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var bufferPool = sync.Pool{
  New: func() interface{} {
    return new(bytes.Buffer)
  },
}

// Get a buffer from the pool
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset() // always reset before use

// ...use buf...

// Return the buffer to the pool
bufferPool.Put(buf)

Best practices:

  • always preallocate slices and maps when you know the expected size;
  • use pooling only for temporary, expensive-to-create objects, not for persistent caching;
  • remember to reset the state of objects before putting them back in the pool;
  • with Go generics, you can create typed pools for extra safety and performance.

These techniques help reduce allocations, improve performance predictability, and ease the garbage collector’s workload, especially in high-load applications or those with many temporary objects.

Concurrency and goroutines 🚦

Goroutines: cheap, but not free

Goroutines are one of Go’s strengths: lightweight, easy to create, and managed efficiently by the runtime. Each goroutine starts with a very small stack (about 2KB) that grows dynamically, allowing you to start thousands or millions of goroutines without immediately saturating memory.

However, “lightweight” does not mean “free”. Every goroutine consumes memory (stack, metadata) and scheduling resources. Creating too many without control can still exhaust RAM or introduce excessive context-switching overhead. In addition, goroutines that never terminate (leaks) or that remain blocked can cause problems that are hard to diagnose.

Best practices for using goroutines:

  • create only the goroutines you genuinely need: consider whether a worker pool or pipeline could replace one goroutine per task;
  • make sure each goroutine has a clear lifecycle and can terminate: use signaling channels (done, context.Context) to manage shutdown;
  • avoid launching goroutines in unbounded loops or in response to uncontrolled input;
  • periodically monitor the number of active goroutines with runtime.NumGoroutine() to spot leaks or anomalies.

Example of correctly managing a goroutine’s lifecycle:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func worker(jobs <-chan int, done <-chan struct{}) {
  for {
    select {
    case job, ok := <-jobs:
      if !ok {
        return // channel closed, terminate the goroutine
      }
      // process the job
    case <-done:
      return // termination signal
    }
  }
}

// Controlled startup and shutdown
jobs := make(chan int)
done := make(chan struct{})
go worker(jobs, done)
// ...
close(done) // signal shutdown

Remember: goroutines are a powerful tool, but they need to be managed with discipline to avoid wasted memory, leaks, and debugging headaches.

Worker pool

For high-load microservices, the worker pool pattern allows many requests to be processed in parallel without saturating memory and while keeping concurrency under control. Rather than creating a goroutine for every task, you start a fixed number of workers that consume jobs from a shared queue. This approach prevents resource exhaustion and improves system stability.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func WorkerPool(tasks []Task, numWorkers int) []Result {
  results := make([]Result, len(tasks))
  jobs := make(chan int, len(tasks))
  var wg sync.WaitGroup
  for worker := 0; worker < numWorkers; worker++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      for job := range jobs {
        results[job] = executeTask(tasks[job])
      }
    }()
  }
  for index := range tasks { jobs <- index }
  close(jobs)
  wg.Wait()
  return results
}

Best practice: better to have a few goroutines working through many jobs than one goroutine per request. Use a worker pool to control concurrency and optimize memory usage.

Channels and synchronization

Channels are at the heart of concurrency in Go: they let goroutines communicate and synchronize safely and idiomatically, without always resorting to mutexes or shared variables. A channel is a thread-safe queue that lets you send and receive values between goroutines, guaranteeing data is transferred in order and without race conditions.

Key principles for using channels:

  • use channels to coordinate the workflow between goroutines, e.g. to distribute tasks to workers or collect results;
  • always close a channel when no more values will arrive: closing signals to receivers that nothing else is coming, avoiding deadlocks and blocked goroutines;
  • prefer buffered channels when you want to decouple producer and consumer, but be careful not to turn them into unbounded queues: a buffer that’s too large can mask synchronization problems;
  • for one-shot synchronization (e.g. signaling the end of a job), use a chan struct{} or context.Context.

Example of a producer/consumer pattern with correct channel closing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
jobs := make(chan int)
results := make(chan int)

// Worker
go func() {
  for job := range jobs {
    results <- job * 2
  }
}()

// Producer
for index := 0; index < 5; index++ {
  jobs <- index
}
close(jobs) // essential: signals the end of the jobs

// Consumer
for result := range results {
  fmt.Println(result)
  // ...
  // close results when done, if needed
}

Advanced synchronization:

  • use sync.WaitGroup to wait for multiple goroutines to finish;
  • use sync.Mutex or sync.RWMutex only when you need to protect shared data that cannot be modeled with channels;
  • atomic operations (sync/atomic) are useful for counters and flags, but they don’t replace structured channel synchronization.

Best practices:

  • design the data flow so that each channel has a single sender responsible for closing it;
  • avoid closing a channel from multiple goroutines: it can cause a panic;
  • prefer immutable data or values passed “by value” over channels, to avoid race conditions.

In short, channels make concurrency in Go safer and more readable, but they must be used carefully: good data-flow design and correctly closing channels are essential to avoid deadlocks, leaks, and hard-to-trace bugs.

Profiling and benchmarking 📏

Profiling: measure before optimizing

Before optimizing Go code, it’s essential to measure where the real bottlenecks are: CPU, memory, and concurrency. Go offers built-in tools for CPU, memory, goroutine, block, and trace profiling, which let you locate and analyze the critical areas of the application.

CPU and memory profiling

To collect and analyze profiles, wire in the net/http/pprof package and start a debug HTTP server:

1
2
3
4
5
6
7
8
9
import _ "net/http/pprof"
import "net/http"

func main() {
  go func() {
    http.ListenAndServe("localhost:6060", nil)
  }()
  // ... rest of the app
}

Start the application and collect the profiles with:

1
2
3
4
5
6
curl http://localhost:6060/debug/pprof/heap > heap.out
curl http://localhost:6060/debug/pprof/profile > cpu.out

go tool pprof -http=:8080 heap.out
# or
# go tool pprof -http=:8080 cpu.out

These tools let you find the functions consuming the most memory or CPU, view flame graphs, and analyze allocations. Memory profiling helps detect leaks and optimize resource usage.

Concurrency profiling

Besides CPU and memory, you can profile concurrency to locate deadlocks, goroutine leaks, and blocking:

  • goroutine profile: how many goroutines are active and where they are in the code;
  • block profile: shows where goroutines get stuck on synchronization operations;
  • threadcreate profile: useful to understand whether the runtime is creating too many OS threads.

Collect the profiles:

1
2
3
4
5
6
curl http://localhost:6060/debug/pprof/goroutine > goroutine.out
curl http://localhost:6060/debug/pprof/block > block.out

go tool pprof -http=:8080 goroutine.out
# or
# go tool pprof -http=:8080 block.out

In the pprof web interface you can explore stack traces, blocking functions, and graphical views to quickly spot critical points.

Practical profiling tips

  • run profiling in environments that reproduce real production load;
  • analyze both total and temporary allocations;
  • use the top, list, and web pprof commands to dig into the costliest functions;
  • if you notice memory leaks, look for blocked goroutines or data structures growing without bound;
  • analyze the block profile to find mutexes or channels causing slowdowns.

Profiling should be part of the development cycle: only that way can you guarantee efficient and scalable Go applications.

Benchmarking

Write benchmark tests to compare performance before and after optimizations. A Go benchmark looks like this:

1
2
3
4
5
func BenchmarkSum(b *testing.B) {
  for index := 0; index < b.N; index++ {
    _ = 1 + 2
  }
}

Run benchmarks with:

1
go test -bench=.

Tracing

Tracing in Go lets you deeply analyze latency, concurrency, and the flow of calls within the application. Unlike plain profiling, tracing provides a detailed timeline of events: you can see when goroutines start and end, how contexts propagate, and where waits on channels, locks, and I/O occur.

To generate and analyze a trace:

1
go test -trace trace.out

This command runs the tests and records a trace file. You can then explore it with:

1
go tool trace trace.out

An interactive web interface opens, showing:

  • the timeline of goroutines and system events;
  • periods of waiting on locks, channels, and I/O;
  • the slowest or most congested code regions;
  • the relationships between concurrent events.

When to use tracing:

  • to find bottlenecks caused by synchronization, locks, or I/O waits;
  • to analyze the end-to-end latency of a request;
  • to understand the sequence and duration of concurrent operations.

Practical tips:

  • run tracing under realistic load to get meaningful data;
  • combine tracing and profiling for a complete performance diagnosis;
  • use custom annotations (runtime/trace) to mark critical code sections.

Tracing is an advanced but very powerful tool: use it to solve latency and concurrency issues and to optimize the flow of your Go applications.

Network optimization for Go microservices

Understanding latency and throughput

Before diving into optimizations, it’s essential to understand what we’re trying to improve:

  • latency: the time needed to process a single request (measured in ms or μs);
  • throughput: the number of requests that can be processed in a given period of time (measured in requests per second).

These metrics often have a complex relationship: optimizing one can sometimes negatively impact the other. The goal is to find the right balance for the specific use case.

Latency = response time per request Throughput = requests processed per second

For example, a service can have low latency but limited throughput, or handle many requests per second with higher response times. The choice of optimizations depends on the type of load and the application’s requirements.

Optimizing HTTP connections

HTTP connections are often the bottleneck in microservices, especially when making many calls between services or to external APIs. Proper connection pooling and timeouts reduce latency, avoid creating excessive connections, and improve resource management.

1
2
3
4
5
6
7
8
var httpClient = &http.Client{
  Transport: &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 100,
    IdleConnTimeout:     90 * time.Second,
  },
  Timeout: 10 * time.Second,
}

Optimizing database connections

Database connections are expensive, limited resources. Properly sizing connection pools and managing their lifetime is essential to avoid bottlenecks, resource saturation, and slow queries. Configuring pool parameters helps keep performance stable even under heavy load.

1
2
3
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
  • batch processing: run batch operations to reduce round trips to the database:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func BatchInsert(users []User) error {
  query := "INSERT INTO users(id, name, email) VALUES "
  vals := []interface{}{}
  for i, user := range users {
    query += fmt.Sprintf("($%d, $%d, $%d),", i*3+1, i*3+2, i*3+3)
    vals = append(vals, user.ID, user.Name, user.Email)
  }
  query = query[:len(query)-1] // remove the trailing comma
  _, err := db.Exec(query, vals...)
  return err
}

Patterns and anti-patterns

Atomic operations and lazy initialization

Synchronize only when necessary. Use sync.Once for safe initialization and atomic operations for counters and flags:

1
2
3
4
var once sync.Once
once.Do(func() {
  // initialization
})

Error handling and best practices

Avoid common mistakes such as ignoring errors, excessive use of cgo, global variables, unnecessary pointers, and accidental circular references. Always handle errors explicitly:

1
2
3
4
5
f, err := os.Open("file.txt")
if err != nil {
  log.Fatal(err)
}
defer f.Close()

Handling circular references

Circular references between data structures can prevent the garbage collector from freeing memory, causing memory leaks. In Go, pay attention to structures that reference each other (e.g. nodes of a doubly linked list, graphs, etc.). If necessary, use weak pointers or decoupling techniques to avoid unnecessary reference cycles.


Optimizing Go is like playing tetris: getting every piece in the right place is the difference between a lean program and a… wobbly one!

Advanced optimizations for Go microservices

Multi-level cache with Redis

Use an in-memory cache (e.g. Ristretto) as a first level and Redis as a distributed second level. This reduces latency and offloads Redis from repeated requests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
type MultiLevelCache struct {
  local *ristretto.Cache
  redis *redis.Client
}

func (c *MultiLevelCache) Get(key string, value interface{}) (bool, error) {
  if val, found := c.local.Get(key); found {
    err := json.Unmarshal(val.([]byte), value)
    return true, err
  }
  val, err := c.redis.Get(context.Background(), key).Bytes()
  if err == nil {
    err = json.Unmarshal(val, value)
    if err == nil {
      c.local.SetWithTTL(key, val, 1, time.Minute)
    }
    return true, err
  }
  return false, err
}

Redis client-side caching: Redis supports a client-side caching mode that lets clients keep a local cache synchronized with the Redis server. This approach further reduces latency and server load, especially with many clients or repeated requests on the same data. More details: Redis Client Side Caching.

TCP rate limiting with Redis

Redis can be used to implement rate limiters and distributed locks between microservices.

An advanced approach applies rate limiting at the TCP connection level, blocking requests before they are even parsed by the HTTP handler. This reduces server load by avoiding parsing and processing requests that would be rejected anyway. This is particularly useful in high-traffic microservices or when facing DoS attacks.

Example of TCP rate limiting with Redis:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// Example: connection-level TCP rate limiting, before HTTP parsing
// Uses Redis to track attempts per IP
import (
  "net"
  "time"
  "github.com/go-redis/redis/v8"
  "context"
  "strconv"
)

var rdb = redis.NewClient(&redis.Options{
  Addr: "localhost:6379",
})

const (
  maxConnPerMinute = 10
)

func tcpRateLimitListener(addr string) error {
  ln, err := net.Listen("tcp", addr)
  if err != nil {
    return err
  }
  defer ln.Close()
  for {
    conn, err := ln.Accept()
    if err != nil {
      continue
    }
    go func(c net.Conn) {
      remoteIP, _, _ := net.SplitHostPort(c.RemoteAddr().String())
      allowed, _ := checkRateLimit(remoteIP)
      if !allowed {
        c.Close() // close the connection immediately
        return
      }
      // ...pass the connection to the HTTP server or other handler...
    }(conn)
  }
}

func checkRateLimit(ip string) (bool, error) {
  ctx := context.Background()
  key := "tcp:ratelimit:" + ip
  count, err := rdb.Incr(ctx, key).Result()
  if err != nil {
    return false, err
  }
  if count == 1 {
    rdb.Expire(ctx, key, time.Minute)
  }
  return count <= maxConnPerMinute, nil
}

In this example, every new TCP connection is checked through Redis: if the number of connections from a given IP exceeds the threshold within the current minute, the connection is closed immediately, without ever reaching the HTTP stack.

Monitoring, profiling, and best practices

  • telemetry and metrics: integrate detailed metrics (e.g. Prometheus) to monitor latency, throughput, errors, and resources;
  • continuous profiling: use tools like pprof and automated benchmarks to find bottlenecks and measure the impact of optimizations;
  • avoid premature optimization: always measure before and after every change;
  • scalability and resilience: consider Redis Sentinel/Cluster for high availability and configure memory/eviction policies based on your needs.

Note: Redis isn’t just a cache: it can be used for rate limiting, session management, distributed locking, Pub/Sub, and job queues.


Useful resources

Last updated on