Why does a Go loop driven by time.NewTicker do nothing for a whole interval after it starts?
answer
- the first interval is silent
- ticks start at d, not at zero
- reorder: work first, wait last
- one run above the loop also works
basics
~20 sA ticker delivers its first value one full interval after it is created, never at time zero. To act at startup, do the work once before the loop, or put the work first in the loop body and the tick wait last.
solid answer
~40 s`time.NewTicker(d)` schedules deliveries at `d`, `2d`, `3d` and so on; there is no tick at time zero. A certificate-renewal daemon built with a six-hour interval therefore sits idle for six hours before its first check, which is exactly the wrong behaviour for something whose job is to reconcile state after a restart. The fix is structural, not a sleep: run the work once above the loop, or — the shape I prefer — put the work at the top of the loop body and the wait at the bottom, so the body reads `doWork(ctx)` then `select { case <-ctx.Done(): return; case <-ticker.C: }`. Create the ticker before that first run and `defer ticker.Stop()`, so the first tick lands one interval after startup rather than one interval after the first run finishes.
code
go · 10 linesticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
renewExpiring(ctx) // runs immediately, then once per tick
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}go deeper
Remember the schedule: first value at one interval, not at zero. Be ready to write the loop that does the work at startup too, and to say where defer ticker.Stop() goes.
Explain why the body-first ordering is equivalent to calling the work once above the loop, and why the ticker should be constructed before that first run rather than after it.
Show that you have thought about restarts: a loop whose interval is longer than the process's lifetime never runs at all, so first-tick behaviour is a reliability property, not a cosmetic one.
Frame it as a convention worth standardising across services: every periodic loop reconciles once at startup, owns its ticker, and exits on cancellation, so nobody has to rediscover this per daemon.
## What a ticker's schedule actually is `time.NewTicker(d)` returns a `*time.Ticker` whose `C` field is a receive-only channel of `time.Time`. The runtime arranges for a value to be delivered on that channel after `d` has elapsed, and then repeatedly every `d` after that. The schedule is `d`, `2d`, `3d`, … relative to the moment the ticker was created. **There is no delivery at time zero.** A ticker is a metronome that starts counting when you build it, not a signal that something should happen right now. That single fact produces one of the most common surprises in Go daemon code. Someone writes a credential-rotation loop with a six-hour interval, deploys it, watches the logs for a minute, and concludes the loop is broken. It is not: it will do exactly what it was told, in six hours. ## Why it matters more than it looks Background loops usually exist to *reconcile* state — sweep every X.509 certificate the process holds and renew any whose `NotAfter` is close. Reconciliation is most valuable right after a start or a restart, because that is the moment the process knows least about the world. A loop that waits a full interval before its first sweep also means that a process which restarts more often than its interval (a crash loop, a rolling deploy, a nightly redeploy) may *never* perform the work at all: every instance dies before its first tick. ## The two idioms The first is to call the work once, explicitly, before the loop: ```go ticker := time.NewTicker(interval) defer ticker.Stop() renewExpiring(ctx) for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: renewExpiring(ctx) } } ``` This is honest but duplicates the call. The second idiom removes the duplication by reordering the loop so the body runs first and the wait is the last statement: ```go ticker := time.NewTicker(interval) defer ticker.Stop() for { renewExpiring(ctx) select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } } ``` The tick case has an empty body: receiving from the channel *is* the wait. Both forms are correct; the second is the one you will see most often in long-lived services, and it makes the "work, then wait" rhythm explicit in the source. ## Where the ticker is created matters Create the ticker **before** the first run, as above. If you instead do the first run and then call `time.NewTicker`, the first tick arrives one interval after that run *finished*, so a slow first sweep silently pushes the whole schedule later. Creating it up front means the interval is measured from startup, and if the first run happens to outlast the interval a tick is already waiting when it returns. ## Stopping it `defer ticker.Stop()` goes immediately after the constructor, in the function that owns the loop. It tells the runtime you are finished with the ticker so it stops being fired. In modern Go an unreferenced ticker can be garbage collected without `Stop`, but while the loop is alive the ticker is very much referenced, so `Stop` is still how a loop that returns — because its `context.Context` was cancelled, for instance — says it is done. It is a one-line habit; skipping it is the sort of thing a reviewer will always flag. ## Things that look like fixes and are not - **Shortening the interval** so the first run comes sooner. That changes the rate of every subsequent run to solve a startup problem. - **`time.Sleep(interval)` at the top of the loop** instead of a ticker. It has the same delayed-start problem, and worse, a sleeping goroutine cannot notice that its context was cancelled. - **Firing the first run in a bare `go renewExpiring(ctx)`** so it happens "in parallel with" the loop. Now the first sweep can overlap the second, and nothing joins that goroutine at shutdown. - **Creating a new ticker inside the loop body.** Each iteration then waits a fresh interval and abandons the previous ticker; you also lose the fixed schedule. ## The shape to remember Create the ticker, defer its `Stop`, do the work, then wait — with the cancellation case sitting beside the tick case so the loop can leave between runs. Everything about the first-run problem falls out of putting those four steps in that order.
- Should the ticker be created before or after that first run, and does it change anything?Before. Then the interval is measured from startup, so the first tick lands one interval after the process began, and if the initial run outlasts the interval a tick is already waiting when it returns. Creating the ticker after the first run measures the interval from when that run finished, which quietly pushes the whole schedule later every time the first sweep is slow.
- Where does defer ticker.Stop() belong, and what does it actually release?Immediately after `time.NewTicker`, in the function that owns the loop. It tells the runtime you are done with the ticker so it stops firing. While the loop is running the ticker is still referenced, so it is not something the garbage collector can take care of for you; `Stop` is the explicit release when the loop returns because its context was cancelled.
- Why not just call time.Sleep(interval) at the top of the loop instead of using a ticker?A sleeping goroutine cannot notice anything. With `time.Sleep` the loop has no way to react to a cancelled `context.Context` until the sleep ends, so shutdown waits out the full interval. Selecting on `ticker.C` beside `ctx.Done()` lets the loop leave the moment cancellation arrives, and it keeps the schedule anchored to the ticker rather than to how long each run took.
saying these in an interview costs you the question
- Believes a ticker delivers a value the instant it is created
- Shortens the interval to make the first run happen sooner
- Replaces the tick wait with time.Sleep inside the loop
- Starts the first run in a goroutine nothing ever joins
- Constructs a new ticker on every loop iteration