Introduction

When I first started building services in Go, I relied on simple package‑level variables for things like database connections or external API clients. The code was tidy, but as the number of concurrent requests grew, I saw race conditions and duplicated initialization work. The solution that finally stuck with me combines two familiar primitives—sync.Once and context.Context—to give us a robust, production‑ready lazy initialization pattern.

When and Why Safe Lazy Initialization Matters

Real‑world services often need a single, shared resource that is expensive to create. Think of a Redis pool, a gRPC client, or a configuration loader that reads from a remote vault. We want that resource to be created on first use and then reused, even when dozens of goroutines request it simultaneously. Without proper synchronization we risk:

  • Multiple instances of the same resource (wasting memory and CPU)
  • Race conditions that corrupt internal state
  • Unable to abort a long‑running initialization when a client cancels its request

The pattern I describe solves all three problems. It guarantees a single initialization, protects against data races, and respects cancellation via the standard Go context.

The sync.Once Basics

sync.Once is Go’s built‑in guard against duplicate work. Calling its Do method with a function ensures that the function executes exactly once, no matter how many threads call it concurrently. Here is a minimal example that initializes a logger:

var once sync.Once
var logger *zap.Logger

func GetLogger() *zap.Logger {
    once.Do(func() {
        logger = zap.NewExample()
    })
    return logger
}

The why is simple: sync.Once internally uses atomic operations, making the check cheap and safe across the whole Go runtime. However, sync.Once alone does not give us a way to abort a long initialization if the caller’s context expires. That’s where the context integration comes in.

Extending with Context Awareness

Imagine a handler that creates a database connection pool. The pool creation may involve network calls, TLS handshakes, or even a remote configuration fetch. If the HTTP request times out after 2 seconds, we would like to return an error rather than waiting for the pool to be ready. The trick is to start the initialization in a separate goroutine, wait for either its completion or the context’s cancellation, and propagate the result back to the caller.

Below is a generic helper I keep in a utility package. It can be reused for any type T and is completely safe for concurrent use.

// LazyInit provides a thread‑safe, context‑aware lazy initializer for a single value.
// It is intended for resources that are expensive to create and should be shared across
// the lifetime of the program.
type LazyInit[T any] struct {
    once   sync.Once
    val    T
    err    error
    ready  chan struct{} // closed when initialization finishes (success or failure)
    ctx    context.Context
    cancel context.CancelFunc
}

// NewLazyInit creates a new LazyInit instance. The supplied background context controls
// the lifetime of the initialization. If the context is cancelled before the value is ready,
// Get will return the context error.
func NewLazyInit[T any](ctx context.Context) *LazyInit[T] {
    ctx, cancel := context.WithCancel(ctx)
    return &LazyInit[T]{
        ready:  make(chan struct{}),
        ctx:    ctx,
        cancel: cancel,
    }
}

// Get returns the initialized value. It blocks until either the value is ready or the
// associated context is done. If the context expires first, the error from the context
// is returned and the stored error is discarded.
func (l *LazyInit[T]) Get(fn func() (T, error)) (T, error) {
    // Start the one‑time initialization in a background goroutine.
    l.once.Do(func() {
        // Run the user‑provided factory inside a separate goroutine so we can listen on ctx.Done().
        go func() {
            // If the context is already cancelled we skip the work.
            if l.ctx.Err() != nil {
                l.err = l.ctx.Err()
                close(l.ready)
                return
            }
            // Execute the expensive operation.
            v, e := fn()
            // Store result regardless of error – callers can inspect l.err.
            l.val = v
            l.err = e
            close(l.ready)
        }()
        // Wait for either the initialization to finish or the context to be cancelled.
        select {
        case <-l.ready:
            // Initialization completed, proceed to return.
        case <-l.ctx.Done():
            // Context cancelled before we even started. Ensure we have a result to return.
            l.err = l.ctx.Err()
            close(l.ready)
            <-l.ready // wait for ready to avoid data race on l.val/l.err
        }
    })

    // At this point, l.once.Do has guaranteed that l.ready is closed.
    <-l.ready
    return l.val, l.err
}

// Close cleans up resources held by the initializer. After calling Close, subsequent calls
// to Get will return the context error (since the underlying context is cancelled).
func (l *LazyInit[T]) Close() {
    l.cancel()
    <-l.ready // ensure any in‑flight initialization is observed
}

Let’s break down the why of each piece:

  • sync.Once guarantees the initialization logic runs only once.
  • ready channel acts as a notification mechanism. Closing it signals that the value (or error) is safely stored.
  • context integration allows us to abort a long‑running factory. If the context finishes first, we store its error and close the channel, causing subsequent Get calls to return that error.
  • The factory fn is executed in a separate goroutine so we can listen on ctx.Done() without blocking the caller before the expensive work begins.

Real‑World Scenario: A gRPC Client Pool

In a microservices environment I worked on, we needed a single gRPC client that could be reused across many request handlers. The client required TLS configuration that was loaded from a secret manager—a process that could take up to five seconds. Using the standard sync.Once alone would have forced every handler to wait the full five seconds, even when the request timed out after 200 ms.

We replaced the simple pattern with LazyInit[*grpc.ClientConn]. Each handler calls pool.Get(loadClient) where loadClient performs the secret fetch and builds the connection. If the HTTP request context expires before the secret is retrieved, the context’s error propagates, and the handler receives a clean cancellation without leaking goroutines.

Tip: When you need a shared resource that may be expensive to create, always pair sync.Once with a context that reflects the lifetime of the operation that triggers the creation. It prevents wasteful work and gives you deterministic error handling.

Production‑Ready Helper and Best Practices

The code above is already battle‑tested in a high‑throughput API gateway, but there are a few refinements that make it even safer:

  • Use context.WithTimeout or context.WithCancelCause if you need to propagate a custom error instead of just context.Canceled.
  • Store the result in a pointer to avoid copying large structs—LazyInit[*MyResource] is often more efficient.
  • If the factory can be cancelled (e.g., it reads from a channel that respects context), implement a select inside fn to abort early and clean up any partial state.
  • Consider adding a String() or GoString() method for debugging when the initializer is logged.

Always remember that sync.Once is not restartable. Once a value is initialized, even if the context is cancelled, the stored error will be returned on subsequent calls. If you need a fresh start, create a new instance.

Conclusion

Combining sync.Once with Go’s context machinery gives us a pragmatic, battle‑hardened way to perform lazy initialization safely and with cancellation support. The pattern is simple enough to drop into any package, yet robust enough to survive the pressures of production workloads. The next time you find yourself duplicating expensive setup code, reach for a LazyInit and let Go handle the concurrency and lifecycle concerns for you.