skip to content

Should a Go library's resource contract be an explicit Close, or a runtime.AddCleanup backstop?

level: principalimportance: should knowfreq 22%

answer

  1. a promise you cannot retract
  2. the other is an operational hedge
  3. who pays when a caller skips it
  4. a silent backstop hides the leak
  5. and it raises your module's Go floor

basics

~20 s

Make an explicit Close the contract: it is the only deterministic release and the only one callers can test. Add a runtime.AddCleanup backstop only for scarce resources, count every time it fires, and never document it as a guarantee.

solid answer

~50 s

The contract is `Close`, because it is the only release the caller controls, the only one that can return an error, and the only one a reviewer can enforce at the call site. A `runtime.AddCleanup` backstop is an operational hedge for resources that are scarce and expensive to leak — descriptors, sockets, memory outside the Go heap — and it must never appear in the documented contract, because once callers learn they can skip `Close` you can never take it away. If I ship one, three things come with it: `Close` is idempotent and calls `Stop` on the registration; the cleanup increments a counter so a leak is visible rather than papered over; and it never does anything whose failure a caller needed to see, such as flushing writes. If lifetimes cannot be trusted to callers at all, the better answer is a scoped `WithX(func(*T) error)` the library closes itself.

code

go · 9 lines
go
type state struct {
	fd   int
	once sync.Once
	err  error
}

func (s *state) close() {
	s.once.Do(func() { s.err = syscall.Close(s.fd) })
}

go deeper

for a junior

The takeaway you can state: Go libraries release resources through an explicit Close that the caller invokes, usually with defer, and anything the runtime might do later is not something you plan around.

for a middle

Be able to argue the mechanics. A runtime cleanup has no guaranteed timing, cannot return an error and cannot be sequenced with the caller's work, which is why it cannot serve as the release path.

for a senior

Show how you would ship it: an idempotent Close that stops the registration, a counter on the cleanup path, a test asserting the backstop stays cold, and documentation that names Close as the contract.

for a principal

Own the irreversibility and the cost. Once callers depend on the hedge you cannot remove it, and requiring Go 1.24 pushes a toolchain floor onto everyone who imports you — weigh both against a scoped API that never hands out a lifetime.

## What is actually being decided This is not a question about which mechanism works. It is a question about what you *promise*, to people you cannot call, in a package you cannot un-ship. Once a documented behaviour exists, other teams write code that depends on it, and in a shared library the cost of removing it later is theirs and the blame is yours. So the decision is: which release path is the contract, and which — if any — is an implementation detail you reserve the right to change. ## Why Close wins the contract Four properties, none of which a runtime hook has. **Determinism.** `Close` releases at a point the caller chose. A cleanup releases at a point nobody chose, possibly never, and definitely not at process exit. **Error reporting.** `Close` returns an error the caller can log, retry, or fail a request on. A cleanup returns nothing to anybody; a failed release inside one is invisible unless you build the visibility yourself. **Testability.** A caller can assert that `Close` was called and that the resource went away. Nobody can write a reliable test for "the runtime eventually did it", because the trigger is a collection cycle they do not control. **Reviewability.** `defer c.Close()` at the call site is a pattern a reviewer, or a vet-style check, can look for. A missing one is visible in a diff. A missing lifetime that the runtime silently covers is visible nowhere. And the resource argument is decisive on its own: descriptors and sockets are far scarcer than memory. A service can sit at 30% heap usage, never trigger a collection, and still exhaust its descriptor limit. Tying scarce OS resources to a memory-pressure signal is the wrong coupling. ## When the backstop earns its place Add one when the failure mode of a forgotten `Close` is severe and slow to attribute — leaked descriptors, leaked connections, memory allocated outside the Go heap — and when releasing late is harmless. Refuse it when releasing late is worse than never: buffered writes that could be flushed after the program decided to abort, protocol messages whose ordering matters, or a release whose double execution is unsafe. If you ship it, ship it with three things. **Disarming.** `Close` must be idempotent and must call `Stop` on the `runtime.Cleanup` value, so the hedge cannot fire on an already-released handle. Guard the underlying release with a `sync.Once` so both paths converge on one execution. **A safe shape.** Split the resource into a small state value — the descriptor plus the `sync.Once` — that the public wrapper points at. Register the cleanup on the wrapper with that state as the argument. The state must not point back at the wrapper, or the object is reachable from its own registration and the cleanup never fires at all. **Observability.** Increment a counter, exposed by the package (an atomic, or an `expvar`), every time the backstop fires, and treat a non-zero value in production as a defect to fix. A backstop that works silently is worse than none, because it converts an obvious bug into a permanent dependency. Do not log at error level from the cleanup itself: it runs on a runtime-owned goroutine and must not block. ## The costs that are not technical **Irreversibility.** Behaviour that callers can observe becomes API whether you documented it or not. If half your consumers stop calling `Close` because nothing breaks, you cannot remove the backstop without breaking them, and you inherit their leaks forever. **A version floor.** `runtime.AddCleanup` arrived in Go 1.24, so requiring it raises your module's minimum Go version, and every module that imports yours inherits that floor. For a widely imported library that is a real cost to weigh, and a reason to leave the hedge out rather than force a toolchain upgrade across an organisation. **Support burden.** When the backstop fires at an awkward moment in somebody else's incident, the investigation lands on your package. You are choosing to own a behaviour that is hard to reason about from the outside. ## The design that avoids the question If the lifetime is genuinely hard for callers to get right, change the shape rather than adding a net. A scoped API — `WithConn(ctx, func(c *Conn) error)` — never hands out a lifetime, so it cannot be leaked, and the library releases the resource on every path including a panic. A pooled API that lends and reclaims does the same thing. Both are more work to design and strictly better than depending on every caller of every version remembering a `defer`. ## How to answer this in a room Say which one is the contract and why, in one sentence. Then name the conditions under which you would add the hedge, the three things you would ship with it, the two costs that are not technical, and the alternative design that removes the decision. What an interviewer is listening for is that you know the difference between a mechanism and a promise.

  • What would make you refuse the backstop entirely?
    Anything where running late is worse than not running at all: buffered writes that could be flushed after the program decided to abort, protocol messages whose ordering matters, or a release whose double execution is unsafe. A cleanup cannot return an error, cannot be sequenced against the caller's work, and cannot be tested by them. When correctness depends on the release, the release belongs in `Close` alone.
  • How do you stop the backstop from hiding the bug it exists to cover?
    Make it loud in aggregate and silent per event. Increment a counter the package exposes — an atomic or an `expvar` — every time the cleanup fires, and treat a non-zero value in production as a defect to fix, not a feature that is working. Add a test asserting the backstop stays cold on the happy path. Do not log at error level from the cleanup: it runs on a runtime-owned goroutine and must not block.
  • Does adding runtime.AddCleanup cost your callers anything besides behaviour?
    Yes. It raises your module's minimum Go version to 1.24, and every module that imports yours inherits that floor. For a widely imported library that is a real cost, and a legitimate reason to guard the hedge behind a build constraint or to leave it out rather than force a toolchain upgrade on every consumer.
  • When is a scoped API better than either option?
    When the lifetime is short and entirely contained in one call tree. `WithConn(ctx, func(c *Conn) error)` never hands out a lifetime, so it cannot be leaked, and the library releases the resource on every path including a panic. It costs the caller some flexibility, and it removes an entire class of support tickets.

saying these in an interview costs you the question

  • Documents the runtime cleanup as a guaranteed release
  • Ships a backstop with no counter, so leaks stay invisible
  • Flushes buffered writes from a cleanup function
  • Forgets to Stop the registration inside Close
  • Ignores that AddCleanup raises the module's minimum Go version