# Lock-free config reloads in Go with atomic.Pointer

> When 100k RPS worth of goroutines read a lookup table that reloads every minute, RWMutex is the wrong tool. Build the new snapshot aside and swap one pointer.

- stage: growing
- published: 2026-05-15
- tags: go, concurrency, performance

---
> 🧪 **Sample seed note.** Placeholder content demonstrating the garden structure — replace with your own text.

A recurring shape in high-load services: a lookup structure — deduplication
set, targeting rules, a denylist — that is **read on every request and
replaced wholesale every N seconds** from some source of truth. Hundreds of
goroutines read it; exactly one goroutine writes it; the write is never an
edit, always a full replacement.

That last property is the key that unlocks everything.

## Why not RWMutex

`sync.RWMutex` is correct here and wrong anyway. Every `RLock` is an atomic
RMW on shared state — at hundreds of thousands of reads per second across
many cores, readers contend with each other on the lock word itself, and the
cache line holding it ping-pongs between cores. You pay this tax on the hot
path to protect against a write that happens once a minute.

`sync.Map` solves a different problem (disjoint key sets, incremental
updates). For build-then-replace workloads it buys complexity, not speed.

## The pattern

Treat the structure as an **immutable snapshot**. Readers grab the current
one; the reloader builds the next one off to the side and swaps a pointer:

```go
type Snapshot struct {
	seen map[uint64]struct{} // never mutated after publish
}

var current atomic.Pointer[Snapshot]

// The zero value of atomic.Pointer is nil — seed it before the hot path
// runs, or every request panics until the first successful reload.
func init() {
	current.Store(&Snapshot{seen: map[uint64]struct{}{}})
}

// Hot path: one atomic load, zero locks, zero contention.
func Seen(id uint64) bool {
	_, ok := current.Load().seen[id]
	return ok
}

// Cold path: runs in one goroutine, e.g. on a ticker.
func reload(ctx context.Context, src Source) error {
	next, err := buildSnapshot(ctx, src) // minutes of work allowed here
	if err != nil {
		return err // keep serving the old snapshot — stale beats broken
	}
	current.Store(next)
	return nil
}
```

`atomic.Pointer` (Go 1.19+) makes this typed and impossible to get wrong at
the call site. A reader that loaded the old snapshot finishes its request on
the old snapshot — perfectly consistent, just up to one reload stale. The
garbage collector retires the old map once the last such reader returns.

## The fine print

- **Immutability is a contract, not a mechanism.** Nothing stops a confused
  reader from writing to the map and racing every other goroutine. Keep the
  map unexported, expose only query functions.
- **Memory doubles during the swap.** Old and new snapshots coexist while the
  build runs. For a 2 GB table, plan 4+ GB — or your "lock-free elegance"
  becomes an OOMKilled pod at the worst hour.
- **Failed reloads must not clear state.** Return early, keep the old
  pointer, and export the moment of last success:

```go
lastReload.Set(float64(time.Now().Unix())) // prometheus gauge
```

  Alert when `time() - last_successful_reload_seconds` exceeds a few reload
  intervals — a service quietly serving week-old rules is the failure mode
  nobody notices until money moves.
- **Writes on the hot path change the game.** If readers must also *mark*
  ids as seen, a read-only snapshot no longer fits — reach for sharded
  mutexes or a pair of rotating generations instead. The atomic-swap trick is
  for read-heavy, replace-rarely data. Know when you've left its territory.