Go Intermediate #4 select and Timeouts
If #3 Goroutines and Channels Intro showed you a single channel — this post is about handling multiple channels at once: the select statement.
select basics
#
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}Among multiple channel operations — it picks one that’s ready and runs it. If multiple are ready, it picks one at random.
The shape resembles switch — but the cases are channel operations. If no case is ready, it blocks.
Common patterns #
1) Timeout #
time.After returns a channel that sends a value after a duration.
select {
case v := <-ch:
fmt.Println("received:", v)
case <-time.After(2 * time.Second):
fmt.Println("timeout")
}If ch doesn’t deliver within 2 seconds — time.After’s channel fires and the timeout case runs. Good for fetch-timeout scenarios.
2) Listening for a cancellation signal #
done := make(chan struct{})
go func() {
for {
select {
case v := <-ch:
process(v)
case <-done:
fmt.Println("shutting down")
return
}
}
}()
// when you want to stop it
close(done)When the done channel is closed — <-done immediately receives the zero value and that case is selected. This is the standard pattern — receive on a closed channel proceeds immediately.
This is what #5 context helps with.
3) Non-blocking send/receive — default
#
A default case in select runs immediately when no other case is ready. Doesn’t block.
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("nothing available")
}“Take it if available, otherwise skip” — no waiting. Works for polling-like cases, but overuse wastes CPU. Channel synchronization is usually more efficient.
select {
case ch <- v:
fmt.Println("sent")
default:
fmt.Println("no receiver — dropping")
}A pattern where you drop work when the queue is full.
time.Tick — periodic work
#
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("every second")
case <-done:
return
}
}time.NewTicker returns a ticker whose channel fires at a fixed interval. Good for simple cron-like periodic work.
There’s also time.Tick — but you can’t stop it, risking leaks. Always NewTicker + defer Stop() to be safe.
select + for — event loop
#
As the snippets above show, the for { select { ... } } pattern is very common. A goroutine that lives forever and handles multiple kinds of events.
for {
select {
case msg := <-incoming:
handleMessage(msg)
case t := <-ticker.C:
heartbeat(t)
case <-shutdown:
fmt.Println("graceful shutdown")
return
}
}Handle three kinds of events (incoming messages, periodic signals, shutdown) all in one place. A shape you’ll see often in server code.
nil channel itself — disabling a case #
A peculiar select behavior — send/receive on a nil channel blocks forever.
var ch chan int // nil
select {
case v := <-ch: // blocks forever (nil)
fmt.Println(v)
case <-done:
return
}Use this to — dynamically enable/disable cases.
var send chan<- int = nil // initially disabled
for {
select {
case v := <-incoming:
// when data arrives, enable the send channel
send = outgoing
buffer = append(buffer, v)
case send <- buffer[0]:
// once sent, drop from the buffer
buffer = buffer[1:]
if len(buffer) == 0 {
send = nil // disable again when there's nothing to send
}
}
}A slightly advanced pattern — sometimes seen inside libraries.
Timeout vs deadline — two meanings #
Two concepts that look similar but differ.
// timeout — N seconds from now
case <-time.After(2 * time.Second):
// deadline — until some absolute time
deadline := time.Now().Add(2 * time.Second)
case <-time.After(time.Until(deadline)):context.Context can express both. Detailed in the next post.
Common pitfalls #
1) time.After can leak
#
time.After creates a new timer per call. If select picks a different case — the timer isn’t GC’d until it fires.
for {
select {
case v := <-ch:
// ...
case <-time.After(time.Second):
// new timer every iteration
}
}Each iteration creates a new timer; if ch delivers frequently, timers can pile up before being GC’d. In hot loops, time.NewTimer with an explicit reset is safer.
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
select {
case v := <-ch:
if !timer.Stop() {
<-timer.C
}
timer.Reset(time.Second)
// ...
case <-timer.C:
// timeout
}
}It’s a bit fiddly. In typical cases — context.WithTimeout is cleaner.
2) select case evaluation order #
for {
select {
case <-fast:
// fires often
case <-slow:
// fires occasionally
}
}When multiple cases are simultaneously ready, Go picks one at random — so a fast case can’t dominate and a slow case won’t starve (anti-starvation).
If you really need priority — split into two stages of select.
for {
// stage 1: priority first
select {
case <-priority:
handle()
continue
default:
}
// stage 2: low priority + priority
select {
case <-priority:
handle()
case <-other:
handleOther()
}
}Not a common pattern, but used when you really need priority.
Practice — HTTP client timeout #
import (
"net/http"
"time"
)
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get("https://example.com")http.Client’s Timeout field is internally implemented with select + context for timeout handling. In practice we mostly use select inside libraries; understanding how it works makes those libraries easier to reason about.
Wrap-up #
What we covered:
selectpicks one ready case from multiple channelstime.Afterfor a timeout caseclose(done)+<-donepattern for cancellation signalsdefaultfor non-blocking send/receivetime.NewTicker+defer Stop()for periodic workfor { select { ... } }as the standard event loop- nil channels block forever — dynamic case disabling
time.Afterleak pitfall —NewTimer+ Reset- Multiple-case selection is random (anti-starvation)
In the next post (#5 context.Context in Depth) — Go’s standard cancellation/timeout tool. We cover how context becomes the skeleton of concurrent code.