Run a Periodic Job in Go Without Letting the Schedule Drift
Run a periodic job in Go without drift: why sleep loops slip, how a ticker holds the schedule, avoiding overlap, running on start, jitter and clock alignment.

"Run this every five minutes" is one of the most common requirements in a service and one of the least precisely specified. Every five minutes from when? Does the run time count? What if a run takes seven minutes? What if a hundred instances all start at once? The code for a periodic job is short, and each short version answers those questions differently, usually by accident.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article compares the three standard shapes with measurements of drift, then adds the pieces a real job needs: no overlap, an immediate first run, jitter, wall-clock alignment and a clean stop. It builds on tickers and context. Everything ran on Go 1.25.6.
The shape that drifts
for {
doWork()
time.Sleep(interval)
}This is the first thing everyone writes. Each cycle takes the work time plus the interval, so a job that takes 5 ms with a 20 ms interval runs every 25 ms. After a hundred cycles it is two and a half seconds behind where a 20 ms schedule would be. Over days, the "every minute" job runs every 61 seconds and skips a minute boundary now and then. A measurement shows the drift:
package main
import (
"fmt"
"time"
)
func work() { time.Sleep(5 * time.Millisecond) }
func main() {
const interval = 20 * time.Millisecond
const runs = 10
// A sleep loop: the schedule slips by the work time every cycle.
start := time.Now()
for range runs {
work()
time.Sleep(interval)
}
sleepLoop := time.Since(start)
// A ticker: the schedule holds, as long as the work fits in the interval.
start = time.Now()
ticker := time.NewTicker(interval)
for range runs {
<-ticker.C
work()
}
ticker.Stop()
tickerLoop := time.Since(start)
fmt.Println("ten cycles, ideal 200ms+5ms:")
fmt.Println(" sleep loop about", sleepLoop.Round(10*time.Millisecond))
fmt.Println(" ticker about", tickerLoop.Round(10*time.Millisecond))
}ten cycles, ideal 200ms+5ms:
sleep loop about 270ms
ticker about 210msTen cycles of a 20 ms interval should span about 200 ms. The sleep loop took about 270 ms, because every cycle adds the 5 ms of work, and each sleep overshoots a little as well, a drift of more than a quarter. The ticker finished in about 210 ms, which is the 200 ms schedule plus the last run's 5 ms: it ticks on the clock regardless of how long the work takes, as long as the work is shorter than the interval. This is the main reason to prefer a ticker for a job that has a schedule.
The shape that overlaps, or skips
What happens when a run takes longer than the interval? With a sleep loop, nothing special: the next run starts after the sleep, so runs never overlap and the schedule stretches. With a ticker, the answer is different. The ticker's channel holds one tick, so while a long run is in progress at most one tick is pending; others are dropped. When the run finishes, the loop immediately receives the pending tick and starts again. The schedule is kept as well as possible without ever running two at once, because the loop is single-threaded, but a long run is followed by an immediate one and the dropped ticks are lost:
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
start := time.Now()
runs := 0
for time.Since(start) < 200*time.Millisecond {
<-ticker.C
runs++
if runs == 2 {
time.Sleep(70 * time.Millisecond) // one run takes 3.5 intervals
}
}
fmt.Println("runs in 200ms (10 ticks would have fired):", runs)
}runs in 200ms (10 ticks would have fired): 8One slow run swallows several ticks: instead of ten runs there were eight, and the slow run is followed by an immediate catch-up tick. For most jobs (refresh a cache, flush metrics) that is exactly the right behavior. For jobs where every interval matters (billing for each minute), a ticker is the wrong tool, and the job should compute what it missed from timestamps.
A fixed gap instead: re-arm a timer
If the requirement is "wait a minute after each run finishes", a timer re-armed after the work gives it exactly, and also guarantees there is no overlap and no catch-up:
package main
import (
"context"
"fmt"
"time"
)
// Repeat runs fn, then waits gap after it returns, until ctx ends.
func Repeat(ctx context.Context, gap time.Duration, fn func(context.Context)) {
timer := time.NewTimer(0) // the first run is immediate
defer timer.Stop()
for {
select {
case <-timer.C:
fn(ctx)
timer.Reset(gap) // measured from the end of the run
case <-ctx.Done():
return
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
var starts []time.Duration
begin := time.Now()
Repeat(ctx, 30*time.Millisecond, func(context.Context) {
starts = append(starts, time.Since(begin).Round(10*time.Millisecond))
time.Sleep(20 * time.Millisecond) // each run takes 20ms
})
fmt.Println("run start times:", starts)
}run start times: [0s 50ms 110ms]Runs started at about 0, 50 and 110 ms (the printed times are rounded to 10 ms, so the third is really nearer 100): each takes 20 ms and then waits 30 ms, so the period is the work plus the gap, 50 ms, and the next run would have started after the 150 ms deadline. That is "drift" in the ticker sense, and it is what you asked for when you asked for a gap. Reset is safe on a fired timer here since Go 1.23 gave timers clean Reset semantics (the timer changes). Use this shape for jobs that must not overlap and do not need a rigid schedule, such as a sync that should rest between runs.
Choosing, in a table
| You want | Use | Behavior on an overrun |
|---|---|---|
| Runs start every N, on the clock | Ticker | Missed ticks are dropped; next run follows at once |
| N between the end of one run and the start of the next | Timer re-armed | The gap is always N; the schedule stretches |
| A run at a specific wall-clock time (every hour on the hour) | Timer to the next boundary | You compute the next time after each run |
| Concurrent runs, each on schedule | Ticker plus a goroutine per tick, bounded | Overlap, so guard shared state |
Never overlap, on purpose
An overrunning job that overlaps itself is a classic production failure: a slow dependency makes runs take longer, overlapping runs add load, which slows the dependency further. If you do start work from the tick in a goroutine, guard with a flag so a new run is skipped while the last is still going:
package main
import (
"fmt"
"sync/atomic"
"time"
)
func main() {
var running atomic.Bool
var started, skipped atomic.Int32
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
end := time.After(105 * time.Millisecond)
for {
select {
case <-ticker.C:
if !running.CompareAndSwap(false, true) {
skipped.Add(1) // the previous run is still going: skip this tick
continue
}
started.Add(1)
go func() {
defer running.Store(false)
time.Sleep(35 * time.Millisecond) // a slow run
}()
case <-end:
fmt.Println("runs started:", started.Load(), "| ticks skipped:", skipped.Load() > 0)
return
}
}
}runs started: 3 | ticks skipped: trueTen ticks arrive; because each run takes 35 ms, only about three start, and the ticks that find a run in progress are skipped and counted. The CompareAndSwap is the lock-free "try to claim" (compare and swap). Counting the skips gives you a metric: a job that skips often is a job that is too slow for its schedule.
Immediate first run, jitter and wall-clock alignment
Run once on start. A ticker's first tick is a full interval away. If the job should run at start-up, call it before the loop (or use the zero-duration timer, as in Repeat). Jitter. If a hundred instances start together with a 5-minute job, they all hit the database at the same second every five minutes. Add random jitter to the first delay, or to every interval: sleep for interval ± 10%, or start the loop after a random delay up to one interval. It spreads load without changing the average rate. Align to the clock only when you must. "Every hour at :00" is a wall-clock requirement; "every hour" is not. For the former, compute the next boundary and sleep until it:
package main
import (
"fmt"
"time"
)
// nextBoundary returns the next multiple of d after now, in UTC.
func nextBoundary(now time.Time, d time.Duration) time.Time {
return now.UTC().Truncate(d).Add(d)
}
func main() {
now := time.Date(2026, 10, 26, 14, 37, 12, 0, time.UTC)
fmt.Println("now: ", now.Format("15:04:05"))
fmt.Println("next hour: ", nextBoundary(now, time.Hour).Format("15:04:05"))
fmt.Println("next 15 min: ", nextBoundary(now, 15*time.Minute).Format("15:04:05"))
fmt.Println("next 5 min: ", nextBoundary(now, 5*time.Minute).Format("15:04:05"))
}now: 14:37:12
next hour: 15:00:00
next 15 min: 14:45:00
next 5 min: 14:40:00Truncate rounds down to a multiple of the duration since the zero time, which for hours and minutes coincides with clock boundaries in UTC. Sleep until the boundary (time.Until), run, and compute the next one again, so a long run does not shift later boundaries. Daylight saving and time zones make local-time schedules harder than they look; for anything beyond "every N minutes" a cron-style scheduler library, or the platform's scheduler (Kubernetes CronJob, systemd timers), is the better answer.
Stopping cleanly, and surviving panics
Every version above ends on a context, and the stop path matters as much as the schedule (stopping a goroutine). A job that panics should not end the schedule: wrap each run in a function with a recover that logs and continues, so one bad iteration cannot silence a job for the rest of the process's life, which is a failure that nobody notices until the data is stale (recover). Log each run's duration and outcome, and export the time of the last success as a metric: a gauge that stops advancing is the most reliable alarm for a periodic job that has silently stopped. Document the guarantees: whether runs can overlap, whether missed intervals are made up, and what happens at shutdown with a run in progress.
The timer types are documented in the time package, and the behavior of ticks that a slow receiver misses is stated in the Ticker documentation.


