Why build a package's shared client behind a sync.Once instead of in an init() function?
answer
- import time versus first use
- init() cannot return an error
- tests import the package too
- who pays, and when failure shows up
basics
~20 sA sync.Once defers the work to first use, so binaries and tests that never touch the package pay nothing, and the accessor can return a failure to its caller. init() runs at import time and can only panic.
solid answer
~50 s`init()` runs when the package is loaded, before `main` starts — before flags are parsed, before configuration is read, and in every binary and every test that imports the package even transitively. It takes no arguments and returns nothing, so a failed dial or a missing environment variable can only panic or be silently stashed. A `sync.Once`-guarded accessor moves that work to the first call that actually needs the value: unused code paths cost nothing, tests can set up their environment before touching the accessor, and the accessor can return `(value, error)` to a caller that knows what to do about it. The price is that the failure now surfaces on the first request rather than at startup, and the first caller pays the latency. That is why the third option — an explicit constructor called by `main`, with the result passed down — is often better than either.
code
go · 12 linesvar (
once sync.Once
client *http.Client
initErr error
)
func shared() (*http.Client, error) {
once.Do(func() {
client, initErr = dial(os.Getenv("SERVICE_ADDR"))
})
return client, initErr
}go deeper
Know the basic difference in timing: init() runs when the package is loaded, before main; a sync.Once accessor runs its function on the first call that needs the value. Be able to point at each shape in code.
Explain the consequences: init() cannot return an error and runs in every binary and test that imports the package, while a lazy accessor defers the cost and can hand a failure to its caller. Note that the first caller pays the latency.
Show that you weigh fail-fast against deferred cost. Say where the failure surfaces in each design, and describe calling the lazy accessor once from main to get start-up validation without changing the callers.
Own the codebase-wide rule. Decide when dependencies must be constructed in main and injected, when a package-level lazy singleton is acceptable, and what init() is allowed to do at all, then make that reviewable.
## The two shapes Eager, at import time: ```go var client *http.Client func init() { client = dial(os.Getenv("SERVICE_ADDR")) } ``` Lazy, on first use: ```go var ( once sync.Once client *http.Client ) func shared() *http.Client { once.Do(func() { client = dial(os.Getenv("SERVICE_ADDR")) }) return client } ``` They differ on *when* the work happens, *who* pays for it, and *how failure travels*. ## What `init()` costs you **It runs whether or not anything needs it.** Package initialisation is driven by the import graph, not by use. A command-line tool that imports the package for one helper function still dials the service at start-up. A unit test for an unrelated function in the same package still dials it. So does `go test` on any package that imports it transitively. **It runs before the program is configured.** `init()` completes before `main` begins, so flags are unparsed and any configuration your program loads in `main` does not exist yet. The only inputs available are environment variables and compile-time constants, which is why eagerly initialised packages so often reach for `os.Getenv` and nothing else. **It cannot report failure.** `func init()` takes no parameters and returns no values. A failure has exactly two outlets: panic — killing a process that might not even need this package — or store the error in a variable and hope every accessor checks it. Neither hands a decision to a caller who could retry, fall back, or degrade. **It is hostile to tests.** A test cannot run before `init()`; the best it can do is set environment variables in `TestMain` before the package under test is loaded, which is fragile, or accept the dial. That makes packages with heavy `init()` slow and awkward to test. ## What the `sync.Once` version buys **Work is paid for by the first use, not by every process.** Binaries and tests that never call the accessor never construct anything. **The accessor has a signature.** Because you control it, it can be `func shared() (*http.Client, error)` and give the error to whoever asked. That alone removes the panic-at-import failure mode. **Setup order becomes explicit.** The value is built after `main` has read flags and configuration, so the initialiser can consume settings that did not exist at import time. **Concurrency is still handled.** The one thing `init()` did give you for free — no concurrent access, because it runs single-threaded before anything else — `sync.Once` restores: exactly one execution, and other callers block until it finishes. ## What lazy initialisation costs in exchange **Failure moves from boot to traffic.** A bad `SERVICE_ADDR` in the eager version stops the process on start-up, where a deployment system will notice. In the lazy version the process starts healthy and the first request discovers the problem — possibly minutes later, possibly under load, possibly on one instance and not another. **The first caller pays the latency.** If construction takes a second, some unlucky request takes a second longer. Under a cold start with a burst of traffic, everyone in that burst waits, because the other callers block inside `Do`. **A hidden global remains a hidden global.** The `sync.Once` accessor is still package-level mutable state with an implicit lifetime. Nothing in a function signature says the function depends on a client, so nothing forces a test to provide one. **Failure is not retried.** A `Once` cannot be re-armed. If the first attempt fails, every later caller in that process gets the same failure — a serious issue for anything that could succeed on a second try. ## The option that is usually right Most production services build their dependencies explicitly: `main` reads configuration, constructs the client, checks the error, and passes it into whatever needs it. That gets fail-fast start-up *and* a testable seam *and* a visible lifetime — including a place to close the thing. `sync.Once` earns its place where a global genuinely is the design: memoising something inside a library that callers should not have to construct, or a value that is expensive and only some code paths need. `init()` remains defensible for cheap, infallible, self-contained work — registering an image format or a database driver with a registry, building a lookup table — where there is nothing to fail and nothing to configure. ## The tell in the logs A useful diagnostic when you inherit such a package: log one line inside the initialiser. With `init()` it appears at start-up in every binary that imports the package. With `sync.Once` it appears at most once, on the first real use, and never again — which tells you both that the guard is working and exactly when the dependency was first needed.
- What does eager initialisation give you that the lazy version cannot?Fail-fast start-up. If the configuration is wrong, the process dies before it is put into service, where a deployment or supervisor notices immediately. A lazily built dependency lets a misconfigured instance report itself healthy and only fail once real traffic arrives — often on one instance out of many, which is far harder to spot.
- How would you get fail-fast behaviour while keeping the lazy accessor?Call the accessor once from `main` (or from a readiness check) immediately after configuration is loaded, and treat its error as fatal there. The `sync.Once` still guarantees one construction, but you have chosen when it happens, so failure surfaces at start-up while the rest of the code keeps calling the same accessor.
- When is init() still the right choice?When the work is cheap, cannot fail, and needs no configuration — building a lookup table, or registering an implementation with a package-level registry so that importing the package is what wires it up. There is nothing to report and nothing to defer, so the extra machinery of a guarded accessor buys nothing.
saying these in an interview costs you the question
- Thinks init() runs on first use of the package
- Believes an init() function can return an error to its caller
- Assumes lazy initialisation is always the better default
- Forgets that the first caller pays the construction latency
- Ignores that a failed lazy init is never retried in that process