skip to content

Timeouts, Timers and Tickers

The idiomatic timeout is a select over your work channel and time.After — but inside a loop that allocates a timer per iteration which nobody stops. Interviewers ask for the timeout pattern first and whether it leaks second.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

How do you use time.After to put a timeout on a channel receive in a Go select?

level: juniorimportance: must knowfreq 80%

answer

  1. make elapsed time look like a channel event
  2. one extra case in the select
  3. returns a channel, does not block
  4. fires once, and cannot be cancelled
  5. the winner of the select decides

basics

~20 s

time.After(d) returns a channel that delivers one value after duration d. Put a receive from it in a select beside the real receive; whichever is ready first wins, so the select never waits longer than d.

solid answer

~40 s

`time.After(d)` returns a `<-chan time.Time` that the runtime sends one value on after `d` has elapsed. You give it a case of its own in a `select`, next to the case you actually want: ```go select { case r := <-readings: // got a reading case <-time.After(2 * time.Second): // gave up after two seconds } ``` `select` blocks until one of its cases can proceed, so this waits for a reading but no longer than two seconds. Two things to be clear about: it is a one-shot channel, not a repeating one, and it does not stop or cancel whatever you were waiting for — the producer keeps running, you just stop listening. There is no handle returned, so you cannot cancel the pending timer; use `time.NewTimer` when you need that.

code

go · 6 lines
go
select {
case r := <-readings:
	_ = r // handle the reading
case <-time.After(2 * time.Second):
	// nothing arrived within two seconds
}

go deeper

for a junior

Be ready to write the four-line select from memory and to say plainly that time.After returns a channel rather than blocking. Knowing it fires exactly once is half the answer.

for a middle

An interviewer expects you to explain that the select proceeds with whichever case is ready first, that the losing timer keeps running with no handle to stop it, and that a time.After inside a loop starts a new window each pass.

for a senior

Show that you separate bounding your own wait from cancelling the other side's work, and that you know when the convenience call is too expensive and a reused time.NewTimer belongs there instead.

for a principal

Frame it as a policy question: where in a system timeouts belong, whether an idle timeout or an overall deadline is the right contract for a component, and how you keep those numbers configurable rather than scattered as literals.

## What `time.After` is `time.After` has the signature `func After(d Duration) <-chan Time`. Calling it starts a one-shot timer inside the Go runtime and immediately returns a receive-only channel. After roughly `d` has elapsed, the runtime delivers exactly one `time.Time` value on that channel — the time at which the timer fired — and then never sends again. The channel is never closed. The reason this shape is useful is that Go's `select` statement waits on several channel operations at once and proceeds with whichever is ready first. Turning "time has passed" into "a channel became readable" makes elapsed time just another event you can wait on alongside real work. ## The idiom ```go select { case r := <-readings: _ = r // handle the reading case <-time.After(2 * time.Second): // no reading arrived within two seconds } ``` If a value is available on `readings` first, that case runs and the `time.After` case is simply never taken. If two seconds pass first, the timeout case runs. If both become ready in the same instant, `select` picks one at random — that is `select`'s defined behaviour and it is not something you can lean on. The same pattern works for a send: `case ch <- v:` beside `case <-time.After(d):` gives you a send that gives up if no receiver appears. ## What it does NOT do This is the part juniors most often get wrong. The timeout bounds **your waiting**, not the other side's work. If a goroutine is computing a reading and you take the timeout branch, that goroutine keeps computing; nothing was cancelled, nothing was interrupted. If it later sends on an unbuffered channel with nobody left to receive, it blocks forever and you have leaked a goroutine. Bounding your own wait and actually cancelling downstream work are two separate jobs. It is also not a sleep. `time.Sleep(d)` parks the calling goroutine for `d` unconditionally. `time.After(d)` returns instantly and hands you a channel; the waiting happens in the `select`, and it can end early. And it is not a ticker. The channel yields one value, ever. Code that expects a heartbeat from `time.After` will fire once and then wait forever. ## Where the timeout restarts A subtlety worth knowing early: `time.After` is evaluated each time the `select` statement is executed. In ```go for { select { case r := <-readings: _ = r case <-time.After(2 * time.Second): return } } ``` every loop iteration calls `time.After` again and starts a **fresh** two-second window. So this means "give up after two seconds of silence", not "give up two seconds from the start". If you wanted a total budget, you must create the timer once, outside the loop, and reuse its channel. ## The cost Every call to `time.After` creates a new runtime timer and a new channel, and returns no handle, so you cannot stop the timer early — you can only ignore it. For an occasional timeout that is irrelevant. In a loop running thousands of times a second it is measurable allocation, and that is the usual reason to reach for `time.NewTimer` and reuse one timer instead. Historically it was worse than allocation: before Go 1.23 the timer created by `time.After` was retained by the runtime until it actually fired, so a hot loop using a long timeout could hold a large number of live timers. Go 1.23 made unreferenced timers collectable by the garbage collector even when they have not fired and were never stopped, so on current Go this is a cost question rather than a leak. ## Reading the value Most code writes `case <-time.After(d):` and throws the value away, but it is a real `time.Time` if you want it: `case t := <-time.After(d):` gives you the moment the timer fired. Note that timers fire *at or after* the requested duration, never before, and scheduling delay means "at or after" can be meaningfully later on a loaded machine.

  • What is actually delivered on the channel that time.After returns?
    A single `time.Time` value: the moment the timer fired, which is at or after the requested duration and never before it. The channel is not closed afterwards, so a second receive on it blocks forever. Most code ignores the value and writes `case <-time.After(d):`, but `case t := <-time.After(d):` is legal if you want the timestamp.
  • If the other select case wins, what happens to the pending timer?
    It keeps running until the duration elapses; you simply never receive its value. You get no handle back from `time.After`, so there is nothing to stop. Since Go 1.23 the garbage collector can reclaim an unreferenced timer even before it fires, so the leftover timer is a small cost, not a leak.
  • Why does putting time.After inside a for-select loop reset the deadline every iteration?
    Because the `select` statement — and therefore the `time.After` call in its case — is evaluated afresh on each pass, starting a brand-new timer. That gives you an idle timeout ("nothing for d"), not an overall deadline. For a total budget, call `time.After` once before the loop and select on the same channel each iteration.

It is an egg timer sitting next to the phone: you answer whichever rings first. The egg timer never tells the kitchen to stop cooking.

saying these in an interview costs you the question

  • Says time.After blocks the goroutine the way time.Sleep does
  • Thinks the timeout case cancels the work being waited on
  • Expects the channel to fire repeatedly like a ticker
  • Believes the channel is closed when the duration elapses
  • Thinks time.After inside a loop keeps one shared deadline
open as a page

When should you use time.NewTimer instead of time.After for a timeout in Go?

level: middleimportance: should knowfreq 55%

basics

~20 s

Use time.NewTimer when you need the handle: it returns a *time.Timer with a C channel plus Stop and Reset, so one timer can be cancelled and reused. time.After returns only a channel, allocating a fresh timer per call.

open as a page

Do you still need to drain a time.Timer's channel before calling Reset, and what breaks if you do?

level: seniorimportance: should knowfreq 42%

basics

~20 s

No. Since Go 1.23 a timer's channel is unbuffered and Go guarantees no value prepared before a Stop or Reset call is delivered after it. The old drain idiom, receiving whenever Stop returns false, can now block forever.

open as a page

What does time.AfterFunc do, and on which goroutine does its function run?

level: middleimportance: nice to knowfreq 32%

basics

~20 s

time.AfterFunc(d, f) schedules f to run once after duration d, on a new goroutine the runtime starts when the timer fires. It returns a *time.Timer used only for Stop and Reset; that timer's C field is nil.

open as a page