A Go daemon's ticker fires every 30s but each renewal run takes 90s. What happens to the ticks in between?
answer
- nobody is receiving while you work
- the ticker keeps no backlog
- at most one tick is pending
- the run's duration becomes the real period
basics
~20 sThey are dropped. The loop body is sequential, so nothing receives while the run is in progress, and a time.Ticker keeps at most one pending tick rather than a backlog. The daemon quietly settles at one run every 90 seconds.
solid answer
~50 sThe loop body runs on the loop's own goroutine, so while the renewal is in flight nobody is receiving from the ticker's channel. `time.Ticker` does not build a queue — the package documents it as dropping ticks to make up for slow receivers, and at most one tick is pending. So with a 30-second interval and a 90-second run you get roughly one run every 90 seconds: when the run returns there is one tick waiting, the next run starts straight away, and the two ticks in the middle are simply gone. Nothing overlaps and nothing is replayed. For an idempotent sweep — walk every certificate and renew whatever is near its `NotAfter` — skipping is the behaviour you want. The real problem is silence: the daemon looks healthy while running at a third of its intended rate, so measure the gap between successive receives and export a missed-tick count.
code
go · 13 lineslast := time.Now()
for {
select {
case <-ticker.C:
if gap := time.Since(last); gap > 2*interval {
missed.Add(int64(gap/interval) - 1) // missed is an atomic.Int64
}
last = time.Now()
renewExpiring(ctx)
case <-ctx.Done():
return
}
}go deeper
Know that the loop is sequential: nothing is received while the body is running, and a ticker does not save up the ticks you missed. One run at a time is the default.
Explain the mechanism precisely — at most one pending tick, the rest dropped — and derive the resulting rate for a given interval and run duration.
Show how you would notice: the daemon looks healthy at a third of its rate, so bring a missed-tick measurement and a decision about whether skipping is acceptable for this particular work.
Own the choice of primitive: reconciliation work that subsumes its predecessors belongs on a ticker, per-interval accounting does not, and that call should be made once for the platform rather than per service.
## Two facts, and everything follows **Fact one: the loop is sequential.** A `for`/`select` loop over a ticker runs its body on one goroutine. Until the body returns, control never gets back to the `select`, so no receive from the ticker's channel can happen. The loop is not "listening in the background"; it is inside your renewal function. **Fact two: a ticker does not queue.** The `time` package is explicit that a ticker adjusts the interval or drops ticks to make up for slow receivers. At most one tick is outstanding. It does not remember that three deliveries were owed and it never fires a catch-up burst. Put those together for a 30-second ticker and a 90-second run: the run starts, ticks at +30s and +60s land with nobody receiving and are discarded, a tick at +90s is pending when the run returns, the `select` takes it immediately, and the next run begins. Steady state is one run per 90 seconds — the handler's own pace — with the ticker contributing nothing but a floor on the rate. ## Why this is usually the right default Periodic work in a daemon is normally *reconciliation*: sweep the certificates this process holds, renew every one whose `NotAfter` is inside the renewal window. Reconciliation is idempotent and each run subsumes the runs it replaced, so a skipped tick costs nothing — the next sweep sees the same world plus 90 seconds of drift. That is very different from a work queue, where every missed item is a missed unit of work. If your periodic action is *not* subsuming — "emit one heartbeat per tick", "bill one interval" — a ticker is the wrong primitive, because it will silently under-deliver and the arithmetic downstream will be wrong. ## The failure mode is invisibility, not correctness The engineer who gets bitten is the one whose nightly refresh grew with the dataset. Nothing errors. No goroutine leaks. No log line says "skipped". The daemon's own metrics — runs completed, renewals performed — all look plausible, because every run that *did* happen succeeded. Only the rate changed, from 120 sweeps an hour to 40, and nobody was watching the rate. So instrument the gap, not the outcome. On each receive, compare the elapsed time since the previous receive with the configured interval and add the surplus to a counter: ```go last := time.Now() for { select { case <-ticker.C: if gap := time.Since(last); gap > 2*interval { missed.Add(int64(gap/interval) - 1) } last = time.Now() renewExpiring(ctx) case <-ctx.Done(): return } } ``` A non-zero missed count is the signal that the loop's real period is set by the handler rather than by the interval you configured. Note that you cannot get this from the channel: since Go 1.23 timer and ticker channels are unbuffered, so `len(ticker.C)` is always zero and was never a reliable backlog gauge anyway. ## The tempting fix, and its cost The reflex is to move the run into a goroutine so the loop keeps receiving: ```go case <-ticker.C: go renewExpiring(ctx) // now runs can overlap without limit ``` This converts a bounded, self-limiting daemon into an unbounded one. If each run takes three intervals, three runs are in flight at all times; if the downstream certificate authority gets slower, the count grows without limit, and now you also have concurrent sweeps racing over the same certificates, duplicate issuance, and memory that climbs until something breaks. If you genuinely need the loop to keep ticking, add a guard so at most one run is in flight — a single-slot channel used as a semaphore, or an `atomic.Bool` you compare-and-swap on entry — and count the skipped attempts. That is the same skipping behaviour as before, just explicit and measurable. ## The honest fixes - **Make the run cheaper** so it fits inside the interval: filter to certificates whose `NotAfter` is inside the renewal window instead of touching all of them. - **Widen the interval** to something the run can actually meet, so the configuration tells the truth. - **Parallelise inside one run** with a bounded number of goroutines, keeping one run in flight at a time. All three keep the invariant that makes a ticker loop easy to reason about: one run at a time, and the interval is a floor rather than a promise.
- When is silently skipping ticks the wrong behaviour for a periodic loop?When each run is not subsuming. A reconciliation sweep absorbs the runs it replaced, so skipping is free. "Emit one heartbeat per interval" or "bill one interval per tick" does not absorb anything: every dropped tick is a lost unit, and the downstream arithmetic goes wrong with no error anywhere. Work like that needs a queue or a counter of elapsed intervals, not a ticker.
- If you move each run into its own goroutine so the loop keeps receiving, what do you trade?Skipped runs for unbounded overlap. If a run takes three intervals, three are always in flight, and if the downstream gets slower the number grows without limit — concurrent sweeps over the same certificates, duplicate work, and climbing memory. If you do it, guard it: a single-slot semaphore channel or an atomic.Bool so at most one run is live, plus a counter of the attempts you refused.
- Can you tell from the ticker's channel that runs were skipped?No. Timer and ticker channels are unbuffered in modern Go, so `len(ticker.C)` is always zero, and even before that it was never a dependable backlog gauge because at most one value was ever held. The measurement has to come from your own bookkeeping: record the time of each receive and compare the gap with the configured interval.
saying these in an interview costs you the question
- Says the missed ticks queue up and replay afterwards
- Thinks each tick automatically gets its own goroutine
- Assumes two runs of the handler can overlap in a plain loop
- Uses len(ticker.C) to look for a backlog
- Fixes an overrunning run by shortening the interval