select & Timeouts
A goroutine often needs to wait for whichever happens first: a reply from one of two servers, a result or a timeout, new work or a stop signal. A plain receive <-ch can wait on only one channel. The select statement waits on several channel operations at once and runs the case that becomes ready first.
In this lesson you will use select to race two sources, add timeouts, make non-blocking channel operations with default, and build a for-select loop driven by a ticker.
Waiting on several channels
select looks like a switch, but every case is a send or receive. It blocks until one case can proceed, then runs that case only. If several are ready at the same moment, it picks one at random, so no channel is starved.
package main
import (
"fmt"
"time"
)
func fetch(server string, delay time.Duration, out chan<- string) {
time.Sleep(delay) // pretend network time
out <- server
}
func main() {
mumbai := make(chan string, 1)
chennai := make(chan string, 1)
go fetch("Mumbai server", 80*time.Millisecond, mumbai)
go fetch("Chennai server", 30*time.Millisecond, chennai)
select {
case s := <-mumbai:
fmt.Println("First reply from", s)
case s := <-chennai:
fmt.Println("First reply from", s)
}
}First reply from Chennai server
The Chennai server answers after 30 ms, so its case runs. Notice the channels have a buffer of 1. The slower goroutine can still deliver its value into the buffer and exit, even though nobody will ever read it. With unbuffered channels it would wait forever, a goroutine leak: a goroutine stuck for the rest of the program's life, holding memory.
Timeouts with time.After
time.After(d) returns a channel that receives one value after the duration d. Put it in a select next to the real work and you have a timeout:
package main
import (
"fmt"
"time"
)
func main() {
result := make(chan int, 1)
go func() {
time.Sleep(300 * time.Millisecond) // a slow payment gateway
result <- 200
}()
select {
case code := <-result:
fmt.Println("Gateway replied with status", code)
case <-time.After(100 * time.Millisecond):
fmt.Println("No reply in 100ms, showing the retry button")
}
}No reply in 100ms, showing the retry button
Every network call in a real program should have a time limit, otherwise one stuck server can freeze your whole app. For timeouts that must travel through many function calls (a web request calling a database calling another service), Go uses context, covered in context: Cancellation & Deadlines.
Non-blocking operations with default
A select with a default case never blocks: if no other case is ready right now, default runs. This gives you "try to receive" and "try to send":
package main
import "fmt"
func main() {
alerts := make(chan string, 1)
select {
case a := <-alerts:
fmt.Println("Alert:", a)
default:
fmt.Println("No alerts")
}
alerts <- "Low stock: sugar"
select {
case alerts <- "Low stock: tea":
fmt.Println("Queued second alert")
default:
fmt.Println("Alert queue full, dropping new alert")
}
fmt.Println("Alert:", <-alerts)
}No alerts Alert queue full, dropping new alert Alert: Low stock: sugar
The buffer holds one alert, so the second send cannot proceed and default runs. Dropping messages when a queue is full is a common choice for logs and metrics, where losing one item is better than freezing the program. Do not put default inside a tight loop with nothing else to do; it would spin and burn CPU.
for-select loops and tickers
Long-running goroutines usually sit in a for loop around a select, reacting to whatever arrives. A time.Ticker sends the current time on its channel C at a regular interval, which is ideal for heartbeats, polling and refreshing a dashboard:
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
stop := time.After(350 * time.Millisecond)
beats := 0
for {
select {
case <-ticker.C:
beats++
fmt.Println("heartbeat", beats)
case <-stop:
fmt.Println("stopping after", beats, "heartbeats")
return
}
}
}heartbeat 1 heartbeat 2 heartbeat 3 stopping after 3 heartbeats
The ticker fires at 100, 200 and 300 ms, and the stop signal arrives at 350 ms. return leaves main. In a function you could also use a labelled break, because a plain break inside select only leaves the select, not the for loop. defer ticker.Stop() releases the ticker when you are done with it.
Two more select tricks
- A closed channel is always ready. Closing a
donechannel makes everycase <-done:in every goroutine fire at once, a simple broadcast "stop" signal. - A nil channel is never ready. Setting a channel variable to
nildisables its case. When merging two input channels, set each one tonilonce it is closed, and leave the loop when both arenil.
An empty select {} blocks forever; you may see it at the end of main in small servers, though waiting for a shutdown signal is better.
Common mistakes
- Unbuffered result channels with timeouts. If the timeout wins, the worker blocks forever on its send. Give the channel a buffer of 1.
- Expecting
breakto leave the loop. Insideselectit leaves only theselect; usereturnor a labelledbreak. - Calling
time.Afterin a hot loop. Each call creates a timer. In a long loop, create one timer or ticker outside and reuse it. - Relying on case order. When several cases are ready, the choice is random, not top to bottom.
Key points
selectwaits on several channel operations and runs the first one that is ready; ties are broken randomly.time.After(d)in aselectgives a simple timeout.- A
defaultcase makes aselectnon-blocking: "try to send" or "try to receive". forplusselectwith atime.Tickerbuilds periodic workers; stop the ticker when done.- Closed channels are always ready (good for broadcast stop signals); nil channels are never ready.
Exercise
A food delivery app asks two restaurants in Kochi for the delivery time. Restaurant A answers after 120 ms and restaurant B after 60 ms. Use select to print the first answer, but give up after 100 ms with the message "Using default estimate: 45 minutes". Run it, then change the timeout to 50 ms and predict the new output.
Show solution
package main
import (
"fmt"
"time"
)
func quote(name string, delay time.Duration, minutes int, out chan<- string) {
time.Sleep(delay)
out <- fmt.Sprintf("%s: %d minutes", name, minutes)
}
func main() {
a := make(chan string, 1)
b := make(chan string, 1)
go quote("Restaurant A", 120*time.Millisecond, 35, a)
go quote("Restaurant B", 60*time.Millisecond, 40, b)
select {
case q := <-a:
fmt.Println(q)
case q := <-b:
fmt.Println(q)
case <-time.After(100 * time.Millisecond):
fmt.Println("Using default estimate: 45 minutes")
}
}Restaurant B: 40 minutes
B answers at 60 ms, before the 100 ms timeout. With a 50 ms timeout, neither restaurant answers in time, so the program prints Using default estimate: 45 minutes. The buffered channels let the late goroutines finish without leaking.
Quick Quiz
1. What does select do when two cases are ready at the same time?
Random choice prevents one channel from always winning.
2. What makes a select non-blocking?
default runs immediately when no other case is ready.
3. Inside for { select { ... } }, what does a plain break in a case do?
break applies to the innermost select; use return or a labelled break to leave the loop.
4. Why give a result channel a buffer of 1 when using a timeout?
Without a buffer, the late worker blocks forever on its send, leaking a goroutine.
Learn Go with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
