EthicsComputerCourses
Join training +91 90532 10052

select & Timeouts

Go · Lesson 24 of 43 Advanced 6 min read Updated

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.

Go
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)
	}
}
Output
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:

Go
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")
	}
}
Output
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":

Go
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)
}
Output
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:

Go
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
		}
	}
}
Output
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 done channel makes every case <-done: in every goroutine fire at once, a simple broadcast "stop" signal.
  • A nil channel is never ready. Setting a channel variable to nil disables its case. When merging two input channels, set each one to nil once it is closed, and leave the loop when both are nil.

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 break to leave the loop. Inside select it leaves only the select; use return or a labelled break.
  • Calling time.After in 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

  • select waits on several channel operations and runs the first one that is ready; ties are broken randomly.
  • time.After(d) in a select gives a simple timeout.
  • A default case makes a select non-blocking: "try to send" or "try to receive".
  • for plus select with a time.Ticker builds 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
Go
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")
	}
}
Output
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?

2. What makes a select non-blocking?

3. Inside for { select { ... } }, what does a plain break in a case do?

4. Why give a result channel a buffer of 1 when using a timeout?

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