A Go daemon starts 300 ticker loops at boot with the same interval, so every check fires together. How do you spread them?
answer
- they all started at the same instant
- phase versus period
- a random offset before the first tick
- math/rand/v2 over one interval
basics
~20 sGive each loop a random phase: before creating its ticker, sleep a random amount drawn from math/rand/v2 over one interval. The loops then spread uniformly while every one of them keeps exactly the same rate.
solid answer
~50 sA ticker's phase is fixed by the instant it was created, and creating 300 of them in one startup loop puts them within microseconds of each other; the alignment then persists for the life of the process, so the daemon does nothing for a whole interval and then hammers the certificate authority 300 times at once. Give each loop a random offset before it starts ticking — `time.Sleep(rand.N(interval))` using `math/rand/v2` — so the phases end up uniformly distributed while each loop's rate stays exactly one run per interval. Use `math/rand/v2` rather than `crypto/rand`: this is load spreading, not a security decision. Bound the jitter so the extra delay cannot push a renewal past the safety margin before `NotAfter`. And note what jitter does not fix — it spreads a burst, it does not make a run that outruns its interval any faster.
code
go · 8 linesfor _, cert := range certs {
go func() {
time.Sleep(rand.N(interval)) // math/rand/v2: offset in [0, interval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
watch(ctx, cert, ticker.C)
}()
}go deeper
Know that a ticker's phase comes from when it was created and never moves, so tickers built together stay together for the life of the process.
Explain why a one-off random sleep before creating the ticker changes the phase but not the rate, and why math/rand/v2 is the right source for that offset.
Argue the bound: jitter is delay, so tie it to the interval and keep it well inside whatever safety margin the work is protecting, and say plainly what jitter does not fix.
Question the shape first — 300 identical schedules is usually one schedule with a bounded fan-out, which gives you one place to control concurrency instead of a distribution to hope about.
## Phase and period A periodic loop has two properties. Its **period** is the interval you configured. Its **phase** is *where in that period its ticks land*, and it is decided entirely by the moment `time.NewTicker` was called. Nothing in Go's runtime staggers timers that share a duration; there is no automatic spreading. If you construct 300 tickers in a single `for` loop at process start, their creation times differ by microseconds, so their phases are effectively identical — and because a ticker's period is fixed, they stay identical for as long as the process lives. The visible result is a sawtooth: the daemon is idle for the whole interval, then does 300 renewal checks in one instant. Every downstream shared resource sees that shape — the certificate authority's rate limiter, the connection pool, the CPU. The average load is unchanged; the peak is 300 times what it needs to be. ## The fix is a random phase Delay each loop by a random fraction of one interval before its ticker exists: ```go for _, cert := range certs { go func() { time.Sleep(rand.N(interval)) // random phase, drawn once ticker := time.NewTicker(interval) defer ticker.Stop() watch(ctx, cert, ticker.C) }() } ``` `rand.N` comes from `math/rand/v2` and is generic over integer types, so it accepts a `time.Duration` directly and returns one in `[0, interval)`. Drawing each offset independently over a full interval gives an approximately uniform spread, and — this is the part people miss — it does **not** change any loop's rate. Each loop still runs exactly once per interval forever; only the alignment moved. `math/rand/v2` also does not need seeding: the global functions are randomly seeded per process, so two replicas of the same binary get different offsets without any work on your part. Since Go 1.22 the loop variable is per-iteration, so the closure capturing `cert` is correct as written; in older code the same loop needed the variable passed in as a parameter. ## Which random source Use `math/rand/v2`, not `crypto/rand`. Spreading load is not a security decision: nobody gains anything by predicting when your renewal check runs, and `crypto/rand` costs more and forces error handling for no benefit. Reaching for the cryptographic generator here is a small tell that a candidate applies rules rather than reasons about them. Deriving the offset from the clock instead — the nanosecond field of `time.Now`, say — defeats the purpose, because every loop that starts in the same instant reads nearly the same value. ## Bounding the jitter Jitter is delay, and delay eats safety margin. A renewal daemon renews when a certificate's `NotAfter` is inside a window — say thirty days out — precisely so that missing a few runs is harmless. Adding up to a full interval of extra delay at startup is fine when the interval is six hours and the window is thirty days. It is not fine if someone later shortens the window or lengthens the interval and the jitter budget quietly becomes the difference between renewing and expiring. Keep the jitter bounded by the interval, keep the safety margin an explicit multiple of the interval, and the two never collide. A common alternative is proportional jitter: run at `interval` but offset by ±10%, which spreads a fleet enough to flatten a spike without a full interval of startup delay. Either shape is defensible; what matters is that the jitter is bounded and expressed in terms of the interval. ## What jitter does not fix Jitter spreads a burst. It does nothing about a run that takes longer than its interval, nothing about a loop that never checks for cancellation, and nothing about a dependency that is simply too slow for the aggregate rate you are asking of it. If 300 checks per interval is more than the downstream can take *on average*, spreading them changes the shape of the load and not its volume; the answer there is fewer checks or a bounded worker pool doing them, not a different random distribution. ## A note on the alternative shape A single ticker fanning work out to 300 items — one loop, one tick, a bounded pool doing the 300 checks — sidesteps the alignment question entirely and is often the better design, because it gives you one place to bound concurrency and one place to measure the schedule. Per-item loops are worth their cost when the items have genuinely different intervals or lifetimes; when they do not, one loop plus a bounded fan-out is simpler and its load profile is something you control directly rather than something you sample from a distribution.
- Why math/rand/v2 rather than crypto/rand for the offset?Because spreading load is not a security property. Nobody benefits from predicting when a renewal check runs, and `crypto/rand` is slower and returns an error you would have to handle for no gain. `math/rand/v2`'s top-level functions are randomly seeded per process, so separate instances of the same binary get different offsets with no seeding code.
- Does sleeping a random fraction of the interval change how often each loop runs?No. It shifts the phase once and leaves the period untouched: every loop still ticks exactly once per interval, forever. The only cost is that the first run of each loop happens up to one interval later than it otherwise would, which is why the jitter needs to stay small relative to whatever deadline the work is protecting.
- Would you ever prefer one ticker fanning work out over 300 separate ticker loops?Usually, yes. One loop plus a bounded fan-out gives a single place to limit concurrency, a single schedule to measure, and a load profile you control rather than sample. Per-item loops earn their keep when the items really do have different intervals or come and go independently; when they share an interval, they are 300 copies of the same schedule and the alignment problem is self-inflicted.
saying these in an interview costs you the question
- Assumes Go staggers timers that share a period
- Reaches for crypto/rand to jitter a schedule
- Seeds the offset from time.Now on every loop
- Applies jitter large enough to blow the renewal deadline
- Expects jitter to fix a run that outruns its interval