Skip to content
beginner Phase 12 · Generics and the Standard Library

Time, Durations, and Tickers

Measure elapsed time, schedule timers, and stop tickers to release resources.

40m
0 problems
Topic Progress 0%

The Request That Took Negative Four Hours

A latency middleware logs end.Sub(start) for every request. One entry reads -4h0m3s. Nothing is broken in the arithmetic: start was captured before the process hit an NTP correction that stepped the wall clock backward, and Sub on wall-clock readings reports the jump. The fix is not a clamp — it is understanding which clock Go used.

time.Now() returns a time.Time that carries two readings: the wall clock (the calendar time you can print) and a monotonic reading (a tick counter guaranteed to move forward on this machine). When both operands come from the same process — Now(), Sub, Since — the subtraction uses the monotonic part, so NTP steps and manual clock changes cannot distort an elapsed measurement:

start := time.Now()
result, err := loadReport(ctx)
elapsed := time.Since(start)
log.Printf("loadReport took %s (err=%v)", elapsed, err)

time.Since(start) is time.Now().Sub(start) with the clock read folded in. Both return a time.Duration: an int64 counted in nanoseconds, with the unit spelled as a multiplier — 250 * time.Millisecond, 90 * time.Second, 2 * time.Hour. The type system is what prevents the classic unit bugs:

timeout := 5 * time.Second              // Duration, not an int that means seconds
budget := timeout - 500*time.Millisecond // subtraction and division work on Durations
average := total / count                 // time.Duration / int -> time.Duration

A configuration file that stores timeout_seconds: 30 arrives as an int, and time.Duration(cfg.Timeout) would silently mean 30 nanoseconds — the kind of bug that only shows up under load. Convert explicitly with time.Duration(cfg.Timeout) * time.Second, or store the value as a string and let time.ParseDuration("30s") reject typos like 30sec at startup instead of at 3 a.m. Formatting a duration for humans needs no code: Duration.String() renders 90*time.Second as 1m30s and 1500*time.Millisecond as 1.5s.

The monotonic reading has one boundary: it disappears when a time crosses a process or serialization boundary. Times parsed from a string, decoded from JSON, or loaded from a database have wall-clock only, and comparing them to a Now() value falls back to wall-clock comparison — correct for calendar questions (expiresAt.Before(now)) and exactly where clock jumps can bite. The rule that follows: use monotonic arithmetic to measure durations inside a process, and wall-clock comparisons for deadlines and expiries that were stored, shipped, or agreed upon with another machine. For cancellation and deadlines that span goroutines, the context timeouts topic already wires WithTimeout to this machinery.

One Flush Loop, Zero Leaked Timers

A metrics agent must flush counters every ten seconds and exit when its context is cancelled. The shape is a for/select around a ticker, and every part of it exists to prevent a specific leak.

func run(ctx context.Context, flush func() error) {
	t := time.NewTicker(10 * time.Second)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			log.Printf("flush loop stopped: %v", ctx.Err())
			return
		case <-t.C:
			if err := flush(); err != nil {
				log.Printf("flush failed: %v", err)
			}
		}
	}
}

time.NewTicker returns a Ticker whose C field is a channel that receives the current time at every interval until stopped. The defer t.Stop() is not tidiness: a ticker nobody stops stays registered with the runtime, and a program that creates tickers per request leaks one timer per request — a small leak that only appears in production memory graphs, which is the exact shape of the goroutine leak hunt. The select gives shutdown equal standing with the tick; without a ctx.Done() case, a Stop() call from another goroutine would leave this loop blocked forever on a channel that will never fire again.

Two behaviors of tickers decide where they are safe. The channel is buffered with capacity one: if flush() takes longer than the interval, ticks are dropped, not queued — the next flush happens as soon as the loop is free. For periodic work that is usually correct (you want one pending flush, not a backlog of a thousand). And ticks are not counted: after a 30-second stall you receive one tick, not three.

time.After deserves suspicion. Inside a select it reads cleanly:

select {
case <-ch:
	handle(ch)
case <-time.After(30 * time.Second):
	return ErrTimeout
}

and it is acceptable there, because the timer is short-lived and fires even if no one receives. But a loop that calls time.After on every iteration creates a timer per iteration, each alive until it fires — with a 30-second timeout and a fast loop, thousands of pending timers. When the timeout must be cancelled early or the loop is hot, allocate one timer and reuse it:

tm := time.NewTimer(wait)
defer tm.Stop()
select {
case v := <-ch:
	handle(v)
case <-tm.C:
	return ErrTimeout
case <-ctx.Done():
	if !tm.Stop() {
		<-tm.C // older modules: drain the value Stop prevented
	}
	return ctx.Err()
}

The drain-after-false Stop is the compatibility idiom for modules whose go directive predates 1.23: historically a value could already be sitting in the channel when Stop returned false, and receiving it prevents a later Reset from firing immediately on stale data. In modules targeting Go 1.23 or later, timer channels no longer carry stale values and Stop/Reset need no drain — keeping the idiom in shared code is harmless, dropping it is correct once the module's go line says otherwise.

One more decision: time.Tick(period) returns a channel with no way to stop it. It is sanctioned for the lifetime of a program — a main loop's ticker — and wrong everywhere else. Any ticker whose lifetime is a request, a test, or a subsystem gets the NewTicker/Stop pair.

Formats You Can Trust and Clocks You Can Fake

A job stores created_at as time.Now().String() — 2026-10-02 14:03:07.123456789 +0000 UTC — and parses it back with a hand-written layout that omits nanoseconds. The first parse fails after a day, when a value happens to render differently. Timestamps crossing a process boundary need a fixed layout, and time.RFC3339 is the one already agreed on by JSON, databases, and HTTP:

s := time.Now().UTC().Format(time.RFC3339) // "2026-10-02T14:03:07Z"
t, err := time.Parse(time.RFC3339, s)

Format takes a layout, and the layout is a sample time — the reference is Mon Jan 2 15:04:05 MST 2006, so the pieces are 2006 (year), 01 (month), 02 (day), 15 (hour), 04 (minute), 05 (second). A custom display layout like "02 Jan 2006 15:04" is fine for humans; storage and wire formats stay RFC3339. Two parsing rules prevent the bugs teams hit monthly:

// Parse assumes UTC when the string carries no zone — wrong for local business hours.
local, err := time.ParseInLocation("2006-01-02 15:04", s, time.Local)

// Build a time from parts instead of string concatenation.
deadline := time.Date(2026, time.March, 14, 23, 59, 59, 0, time.UTC)

Store times in UTC and convert only at the display edge. Arithmetic on wall-clock times uses the calendar rules — AddDate(0, 0, 1) adds a day across a DST boundary while Add(24*time.Hour) adds exactly 86,400 seconds — which is the right distinction between "same time tomorrow" and "twenty-four hours from now."

The last piece is testability. A Session.Expired() that calls time.Now() internally cannot be tested without sleeping until the real expiry, and tests that sleep are slow and flaky. Pass the clock in:

func (s *Session) Expired(now time.Time) bool {
	return !now.Before(s.ExpiresAt)
}

func TestSession_Expired(t *testing.T) {
	now := time.Date(2026, 3, 14, 12, 0, 0, 0, time.UTC)
	s := Session{ExpiresAt: now.Add(time.Hour)}
	if s.Expired(now) {
		t.Error("session expired an hour before its deadline")
	}
	if !s.Expired(now.Add(2 * time.Hour)) {
		t.Error("session still valid two hours after expiry")
	}
}

A now time.Time parameter or a func() time.Time clock field turns time into data: the test controls it exactly, production passes time.Now, and nothing sleeps. The same seam is what lets test doubles treat the clock like any other dependency — storage, network, and time are the three edges worth injecting.

Quiz

1. A latency log shows an elapsed time of -4h0m3s after an NTP clock step. What is the correct fix?

Question 1 options

2. Config holds `flush_seconds: 30` as an int, and the code does `time.Duration(cfg.FlushSeconds)` for a ticker interval. What happens?

Question 2 options

3. A service creates time.NewTicker per HTTP request in a loop and never calls Stop. What is the consequence?

Question 3 options

4. You need to test that a session expires exactly one hour after issue without sleeping. What is the idiomatic approach?

Question 4 options

Flashcards

Question

What does a time.Time value carry beyond the calendar time, and when is it used?

Answer

A monotonic clock reading, captured when the value came from time.Now in this process. Sub, Since, and Until use it when both operands have it, so elapsed-time measurement is immune to NTP steps and manual clock changes. Parsed or serialized times have wall-clock only.

Question

Why does `time.Duration(30)` mean 30 nanoseconds, and what are the two correct conversions?

Answer

Duration is an int64 in nanoseconds, so a bare int carries no unit. Write the unit explicitly — `time.Duration(n) * time.Second` — or parse a string with time.ParseDuration("30s"), which also rejects malformed config at startup.

Question

What happens to ticker ticks when the receiving loop runs slower than the interval?

Answer

The channel holds one buffered value, so excess ticks are dropped rather than queued: you get one pending flush, never a backlog, and a stalled loop receives a single tick when it resumes. For accumulating work, add to counters on each tick instead of doing the work in the loop.

Question

When is time.After acceptable, and when should you use time.NewTimer instead?

Answer

Inside a select as a one-shot timeout is fine — the timer fires even if unreceived. In a loop it allocates a live timer per iteration that survives until it fires; allocate one NewTimer, reuse it across iterations, and call Stop (plus the pre-1.23 drain) when abandoning it.

Question

Why should timestamps be stored with time.RFC3339 instead of Time.String()?

Answer

String() output is a debugging format — its exact shape is not a contract, so a parse based on it breaks when rendering differs. RFC3339 is the shared interchange format (ISO 8601 profile) that JSON, SQL drivers, and HTTP all agree on, and Parse rejects anything else cleanly.

Revision Notes

Key Takeaways

  • 1. time.Now carries a monotonic reading used by Sub/Since, which is why in-process elapsed time ignores NTP steps while parsed times do not
  • 2. Duration is nanoseconds — every unit must be written as a multiplier or parsed with ParseDuration, never assumed
  • 3. Tickers and timers are live runtime resources: NewTicker pairs with Stop, and loops need a ctx.Done case to exit
  • 4. Ticks are dropped, not queued, when the receiver is slow; time.After inside a loop creates one abandoned timer per iteration
  • 5. RFC3339 for anything leaving the process, UTC for storage, and an injected clock for tests that must not sleep

Interview Tips

  • • "How would you measure a request's latency?" — time.Since(start) with a note that monotonic subtraction makes it immune to clock adjustments, and Duration.String() for the log line
  • • "What's wrong with time.Duration(configInt)?" — it is nanoseconds; give the two fixes (explicit multiplier, ParseDuration) and say why the string form fails fast at startup
  • • "How do you write a periodic job?" — NewTicker, defer Stop, select against ctx.Done, and mention ticks coalescing when the work overruns
  • • "How do you make time-dependent code testable?" — inject now as a parameter or clock function; show the fixed time.Date test and explain why sleeping is not an option

Cheat Sheet

Time, Durations, and Tickers

Choose the operation:

Need Call
elapsed time in-process start := time.Now() then time.Since(start)
human duration text d.String() — 90 * time.Second renders 1m30s
config string to duration time.ParseDuration("30s")
int with an explicit unit time.Duration(n) * time.Second
fixed calendar point time.Date(2026, time.March, 14, 23, 59, 59, 0, time.UTC)
periodic work time.NewTicker(interval) + defer t.Stop()
one-shot timeout in select time.NewTimer(d), reuse across iterations
parse/store time.Format(time.RFC3339) / time.Parse(time.RFC3339, s)
parse without a zone in the string time.ParseInLocation(layout, s, loc)

Loop template:

func run(ctx context.Context, every time.Duration, work func() error) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			if err := work(); err != nil {
				log.Printf("tick failed: %v", err)
			}
		}
	}
}

Rules: monotonic arithmetic measures durations; wall-clock comparisons handle stored deadlines. Store UTC, convert at the display edge. AddDate is calendar-aware, Add(24*time.Hour) is exactly 86,400 seconds. Inject now time.Time (or func() time.Time) into anything you need to test without sleeping.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading