Go’s garbage collector is often cited as a performance bottleneck, but a surprising amount of high-performance Go code is effectively zero-allocation.

Consider a network server handling millions of requests per second. Without careful tuning, the constant churn of small allocations for request data, headers, and buffers can overwhelm the GC.

Here’s a look at a simplified request handler that aims for zero-allocation:

package main

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"sync"
)

// RequestContext holds reusable resources for a request.
type RequestContext struct {
	buf    bytes.Buffer // Reusable buffer
	reader io.Reader    // Reusable reader (if applicable)
	// other reusable fields...
}

// pool of RequestContext objects.
var contextPool = sync.Pool{
	New: func() interface{} {
		return &RequestContext{
			buf: bytes.Buffer{}, // Pre-allocate or manage capacity here if known
		}
	},
}

func (rc *RequestContext) Reset() {
	rc.buf.Reset()
	rc.reader = nil // Or reset to a default state
}

func handler(w http.ResponseWriter, r *http.Request) {
	// 1. Get a context from the pool.
	ctx := contextPool.Get().(*RequestContext)
	defer func() {
		// 2. Return the context to the pool when done.
		ctx.Reset() // Clean up for the next user
		contextPool.Put(ctx)
	}()

	// 3. Use the pre-allocated buffer instead of creating a new one.
	// Example: reading request body into the buffer
	_, err := ctx.buf.ReadFrom(r.Body)
	if err != nil && err != io.EOF {
		http.Error(w, "Error reading request body", http.StatusInternalServerError)
		return
	}

	// Process data in ctx.buf
	processedData := bytes.ToUpper(ctx.buf.Bytes()) // Note: bytes.ToUpper *does* allocate a new slice. We'll address this.

	// Write response
	w.Header().Set("Content-Type", "text/plain")
	w.WriteHeader(http.StatusOK)
	_, err = w.Write(processedData)
	if err != nil {
		// Log error, but don't panic or allocate for error response here.
		fmt.Printf("Error writing response: %v\n", err)
	}
}

func main() {
	http.HandleFunc("/", handler)
	fmt.Println("Starting server on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		panic(err)
	}
}

The core problem this solves is the overhead of memory allocation and garbage collection. Every make([]byte, n), append(), bytes.Buffer{}, string concatenation, or even some standard library functions can trigger an allocation. In a hot path, this adds up. By reusing bytes.Buffer and other data structures via sync.Pool, we eliminate allocations for these objects.

Internally, sync.Pool maintains a set of temporary objects that can be borrowed and returned. When Get() is called, it retrieves an existing object or creates a new one if the pool is empty. When Put() is called, the object is returned to the pool. The New function is crucial: it defines how to create a fresh instance of the object when needed. The Reset method on our RequestContext is equally vital; it ensures that the object is in a clean state before being returned to the pool, preventing data leakage between requests.

The bytes.Buffer is a prime candidate for reuse. Instead of creating a new bytes.Buffer for each request to read the body into, we grab one from the pool, use it, and then Reset() it. This reclaims the underlying byte slice and its capacity.

Now, about that bytes.ToUpper call: bytes.ToUpper does allocate a new slice. To truly achieve zero-allocation for this operation, you’d need to work with a pre-allocated buffer of sufficient size and potentially use a custom, in-place transformation function or carefully manage buffer capacity and slicing. For instance, you might pre-allocate a buffer in RequestContext with a known maximum size and then use io.Copy into a portion of that buffer, or use a custom ToUpperInPlace function if available.

One subtle point often missed is the allocation that occurs within standard library functions that seem innocuous. For example, fmt.Sprintf is a common culprit. Even simple string formatting can trigger allocations for the resulting string. If you need to format strings in a high-performance path, consider using bytes.Buffer and its WriteString methods, or a specialized string builder that reuses its internal buffer, rather than fmt.Sprintf. Another example is json.Marshal; while it returns []byte, the internal operations to build that byte slice can involve many allocations. If you’re marshaling to a known structure repeatedly, you might consider pre-allocating the output buffer or using a specialized encoder that pools its internal buffers.

The next hurdle is managing goroutine lifecycles efficiently, especially in highly concurrent applications.

Want structured learning?

Take the full Golang course →