Question

Repeating Tasks at Intervals in Go with time.Ticker and Timers

Question

In Go, is there a clean way to run a repetitive background task at fixed intervals, similar to Timer.schedule(task, delay, period) in Java?

I know this can be done with a goroutine and time.Sleep(), but I would prefer an approach that is easier to stop cleanly.

For example, I currently have code like this:

func oneWay() {
    var f func()
    var t *time.Timer

    f = func() {
        fmt.Println("doing stuff")
        t = time.AfterFunc(5*time.Second, f)
    }

    t = time.AfterFunc(5*time.Second, f)

    defer t.Stop()

    // Simulate doing other work
    time.Sleep(time.Minute)
}

This works, but it feels awkward. Is there a cleaner or more idiomatic way to schedule repeating work in Go and stop it when needed?

Short Answer

By the end of this page, you will understand how to run repeated tasks in Go using time.Ticker, when to use time.Timer instead, and how to stop background work cleanly with channels or cancellation patterns.

Concept

In Go, the usual way to perform work repeatedly at fixed intervals is with time.Ticker.

A Ticker sends the current time on its channel at regular intervals. You can listen for those ticks in a loop and run your task each time one arrives.

time.Timer and time.AfterFunc are better for one-time scheduling. They can be reused in advanced cases, but for simple repeating work they are usually less clear than a ticker.

This matters because background jobs are common in real programs:

  • refreshing cache entries
  • polling an API
  • cleaning expired sessions
  • writing periodic metrics
  • retrying work on a schedule

In Go, the idiomatic approach is usually:

  • run the repeated task inside a goroutine
  • trigger it with a time.Ticker
  • stop it with ticker.Stop()
  • optionally listen for a done signal to exit cleanly

This keeps the code readable and makes shutdown behavior explicit.

Mental Model

Think of a Ticker like a metronome.

Every few seconds, it makes a click. Your program waits for each click, then does a piece of work.

A Timer is more like a one-time alarm clock:

  • it rings once after a delay
  • if you want it again, you must set it again

So if your job is repeat every 5 seconds, a ticker matches the idea directly. If your job is run once after 5 seconds, a timer is the better fit.

Take Quiz

Syntax and Examples

The basic syntax for repeated work in Go looks like this:

ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for range ticker.C {
    fmt.Println("doing stuff")
}

This loop runs forever, printing every 5 seconds.

A more practical version includes a way to stop:

package main

import (
    "fmt"
    "time"
)

func main() {
    done := make(chan struct{})

    go func() {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()

        for {
            select {
            case <-ticker.C:
                fmt.Println("doing stuff")
            case <-done:
                fmt.Println("stopping")
                return
            }
        }
    }()

    time.Sleep(16 * time.Second)
    close(done)

    time.Sleep(1 * time.Second)
}

What this does

  • time.NewTicker(5 * time.Second) creates a repeating signal every 5 seconds.
  • The goroutine waits in a select statement.
  • When the ticker fires, the task runs.
  • When done is closed, the goroutine exits.
  • ticker.Stop() prevents the ticker from continuing to send ticks.

If you only need a one-time delay, use a timer instead:

timer := time.NewTimer(5 * time.Second)
<-timer.C
fmt.Println("runs once after 5 seconds")

Step by Step Execution

Consider this example:

package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()

    done := make(chan struct{})

    go func() {
        time.Sleep(7 * time.Second)
        close(done)
    }()

    for {
        select {
        case t := <-ticker.C:
            fmt.Println("tick at", t.Format("15:04:05"))
        case <-done:
            fmt.Println("finished")
            return
        }
    }
}

Step by step

  1. A ticker is created with a 2-second interval.
  2. A done channel is created for shutdown.
  3. A goroutine waits 7 seconds, then closes done.
  4. The main loop starts a select.
  5. After about 2 seconds, ticker.C receives a value.
  6. The program prints the tick time.
  7. After about 4 seconds, another tick arrives.
  8. After about 6 seconds, another tick arrives.
  9. After about 7 seconds, the done channel is closed.
  10. The select chooses the done case.
  11. The program prints finished and returns.

Important detail

If your task takes longer than the ticker interval, ticks do not queue up forever in the way many beginners expect. A ticker is a timing signal, not a job scheduler with a backlog. If exact scheduling matters, you may need a different design.

Real World Use Cases

Repeated interval-based work is common in Go applications.

Polling an external API

ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()

for range ticker.C {
    // fetch latest data
}

Useful for:

  • checking payment status
  • syncing inventory
  • refreshing exchange rates

Cleaning expired data

A web server may remove old sessions every minute.

for range ticker.C {
    // delete expired sessions from memory or database
}

Writing metrics or heartbeats

A service may report health every 10 seconds.

  • CPU usage
  • queue length
  • memory stats
  • "I am alive" heartbeats

Cache refresh

Applications often refresh cached configuration or feature flags periodically rather than reloading on every request.

Take Quiz

Real Codebase Usage

In real projects, developers usually combine interval logic with a few common patterns.

Guard clauses inside the tick handler

case <-ticker.C:
    if !isLeader {
        continue
    }
    runCleanup()

This prevents work from running unless certain conditions are true.

Early return on shutdown

case <-done:
    return

This is one of the cleanest ways to stop a background goroutine.

Error handling inside periodic jobs

case <-ticker.C:
    if err := syncData(); err != nil {
        log.Println("sync failed:", err)
    }

Background jobs should usually log errors instead of crashing the whole program.

Context-based cancellation

In larger codebases, context.Context is often used instead of a custom done channel.

func worker(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-ticker.C:
            doWork()
        case <-ctx.Done():
            return
        }
    }
}

This integrates well with servers, request lifecycles, and graceful shutdown.

Avoid overlapping work

If the task may take longer than the interval, some codebases skip a run if the previous one is still active, or run the task synchronously in the same loop to prevent overlap.

Common Mistakes

1. Using time.Sleep() in a manual infinite loop

This works, but is often less flexible to stop cleanly.

for {
    time.Sleep(5 * time.Second)
    doWork()
}

Why it is a problem:

  • shutdown can be delayed until sleep finishes
  • cancellation is less explicit
  • it is harder to coordinate with other signals

Prefer a ticker with select when you need control.

2. Forgetting to stop the ticker

Broken example:

ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
    doWork()
}

If the loop exits elsewhere or the goroutine ends unexpectedly, the ticker may keep running longer than needed.

Better:

ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

3. Using time.AfterFunc for repeating work when a ticker is clearer

Your original approach reschedules itself:

f = func() {
    doWork()
    t = time.AfterFunc(5*time.Second, f)
}

This can work, but it is harder to read and reason about than a normal loop with a ticker.

4. Accidentally creating overlapping executions

for range ticker.C {
    go doWork()
}

If doWork() takes longer than the interval, multiple copies may run at the same time.

Avoid this unless concurrency is intentional.

5. Assuming ticks are exact forever

Tickers are good for periodic work, but they are not a perfect real-time scheduler. Execution time, CPU load, and blocking operations can affect when work actually runs.

6. Confusing timer and ticker

  • Timer: one event after a delay
  • Ticker: repeated events at an interval

Comparisons

ToolBest forRepeats automaticallyCan be stoppedTypical use
time.SleepSimple delaysNoNo direct cancellationPause current goroutine
time.TimerOne-time delayed executionNoYesRun once later
time.AfterFuncRun a function once after a delayNoYesFire a callback later
time.TickerRepeated interval-based workYesYesBackground periodic jobs

time.Ticker vs recursive time.AfterFunc

ApproachProsCons
time.TickerClear, idiomatic, easy to stopNot ideal for very custom rescheduling
Recursive time.AfterFuncFlexible for dynamic next-run timingHarder to read and manage

done channel vs context.Context

ApproachBest forNotes
done channelSmall programs or focused examplesSimple and explicit
context.ContextReal applications and librariesStandard cancellation pattern in Go
Take Quiz

Cheat Sheet

// Repeat every 5 seconds
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

for {
    select {
    case <-ticker.C:
        doWork()
    case <-done:
        return
    }
}

Quick rules

  • Use time.Ticker for repeated work.
  • Use time.Timer or time.AfterFunc for one-time delayed work.
  • Always call ticker.Stop() when finished.
  • Use select with a done channel or ctx.Done() for clean shutdown.
  • Be careful about overlapping work if each tick launches a goroutine.

Common patterns

// One-time delay
<-time.NewTimer(3 * time.Second).C

// Simple repeated loop
for range ticker.C {
    doWork()
}

// Stoppable repeated loop
for {
    select {
    case <-ticker.C:
        doWork()
    case <-done:
        return
    }
}

Edge cases

  • If work takes longer than the interval, exact timing may drift.
  • ticker.Stop() stops future ticks, but does not close ticker.C.
  • If you need immediate first execution, call doWork() once before starting the loop.

FAQ

How do I run a function every few seconds in Go?

Use time.NewTicker(interval) and loop over ticker.C, usually inside a goroutine.

What is the difference between time.Timer and time.Ticker in Go?

A timer fires once after a delay. A ticker keeps firing at regular intervals until stopped.

How do I stop a repeating goroutine in Go?

Use a done channel or context.Context, and return from the goroutine when cancellation is signaled.

Is time.Sleep() bad for repeated tasks in Go?

Not always, but it is less convenient for clean shutdown and coordination than time.Ticker.

Does ticker.Stop() close the ticker channel?

No. It stops future ticks, but the channel is not closed.

Can a periodic task overlap with itself?

Yes, if you launch a new goroutine on every tick and the previous run has not finished yet.

Should I use time.AfterFunc for repeated scheduling?

Usually no. It is possible, but time.Ticker is simpler and more idiomatic for fixed intervals.

Take Quiz

Mini Project

Description

Build a small background worker in Go that prints a message every 3 seconds and stops cleanly after 10 seconds. This demonstrates the idiomatic pattern for repeated tasks using a ticker and a stop signal.

Goal

Create a stoppable periodic worker using time.NewTicker, a goroutine, and a shutdown channel.

Requirements

  • Start a background worker that runs every 3 seconds.
  • Print a timestamped message each time the worker runs.
  • Stop the worker cleanly after 10 seconds.
  • Ensure the ticker is stopped when the worker exits.
Take Quiz

Keep learning

Related questions

View more