Scheduled Work
Run work on a clock with a ticker or a cron schedule, and make sure only one replica runs it.
t := time.NewTicker(d); defer t.Stop(); for range t.C { job() }Scheduled work runs on a clock rather than in response to a request: expire password reset tokens every minute, send a digest at 07:00.
Tickers
time.NewTicker(d) returns a *time.Ticker. Its C field is a channel that receives a value every d. Read from that channel in a loop and do the work on each receive. Call Stop when you are finished with it. For "every five minutes while this process is running", that is the whole mechanism.
time.Tick(d) is a shorthand for the same thing: it creates a ticker and hands you only the channel, so you cannot stop it. Since Go 1.23 the garbage collector can reclaim an unreferenced ticker even if it was never stopped, so Tick is safe in a program that does not need Stop. Before Go 1.23 it leaked.
Cron schedules
A ticker counts from the moment the process started. "07:00 every day" is a calendar, not an interval. The standard library has no cron expression parser, so that schedule comes from a library or from something outside the process, such as a Kubernetes CronJob that starts a separate short-lived pod.
What trips people up
Run three replicas of your service and the ticker runs in all three. Three digests. Three choices: move the schedule out of the service entirely; elect one leader with a database lock such as pg_try_advisory_lock; or insert a row keyed by the slot, for example (digest, 2026-10-03), with a unique constraint, and let whichever replica loses that insert skip the work. The last also protects you against a restart running the same slot twice.
Example
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
t := time.NewTicker(10 * time.Millisecond)
defer t.Stop() // release the ticker when the loop ends
runs := 0
for {
select {
case <-t.C: // one value per tick
runs++
fmt.Println("run", runs)
if runs == 3 {
cancel() // in a real service this comes from shutdown
}
case <-ctx.Done(): // the loop must be stoppable, or it outlives the service
fmt.Println("stopped after", runs, "runs")
return
}
}
}
// Output:
// run 1
// run 2
// run 3
// stopped after 3 runsWhen to use it
- Expire password reset tokens once a minute.
- Send a daily digest at 07:00 without sending one per replica.
- Refresh a cached exchange rate table every five minutes.
- Sweep abandoned carts hourly, skipping the tick if the last sweep is still running.
More examples
Shorthand and longhand for a repeating loop
Both run the body once a minute, forever. Use Tick in a program whose schedule lives for the whole run; use NewTicker when something has to stop it, change the interval with Reset, or exit a goroutine cleanly.
// Longhand: you hold the ticker, so you can stop or reset it.
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
expireTokens()
}
// Shorthand: the same loop, but time.Tick gives you only the channel.
// Since Go 1.23 the unreferenced ticker is collected, so this does not leak.
for range time.Tick(time.Minute) {
expireTokens()
}Stopping runs from overlapping
Needed as soon as a scheduled job can take longer than its interval, which is most jobs that touch a database. Skipping a tick is almost always better than two copies of the same sweep competing for the same rows.
var running sync.Mutex
func every(d time.Duration, job func()) {
t := time.NewTicker(d)
defer t.Stop()
for range t.C {
// TryLock (Go 1.18) returns false when the previous run is still going.
// The ticker does not wait for your work, so without this guard a slow
// run and the next one execute at the same time.
if !running.TryLock() {
continue // skip this tick rather than pile up
}
go func() {
defer running.Unlock()
job()
}()
}
}One replica at a time, using a database lock
Use this when the schedule has to live inside the service and you already have Postgres. Note the explicit db.Conn: taking the lock on a pooled connection and releasing it on a different one does not work.
// Only one session can hold a given advisory lock, so only one replica runs
// the job. A session-level lock is held until it is unlocked or the session
// ends: sql.Conn.Close only returns the connection to the pool, so the unlock
// has to be explicit, otherwise the lock is never released and no replica can
// ever take it again. A crashed process does end its session, which frees it.
func runIfLeader(ctx context.Context, db *sql.DB, key int64, job func()) error {
conn, err := db.Conn(ctx) // an advisory lock belongs to one connection
if err != nil {
return err
}
defer conn.Close()
var got bool
if err := conn.QueryRowContext(ctx,
`SELECT pg_try_advisory_lock($1)`, key).Scan(&got); err != nil {
return err
}
if !got {
return nil // another replica is the leader this tick
}
// Unlock on the same connection, and with a context that is still live even
// if ctx was cancelled while the job ran.
defer func() {
_, _ = conn.ExecContext(context.WithoutCancel(ctx),
`SELECT pg_advisory_unlock($1)`, key)
}()
job()
return nil
}
Discussion