Go Intermediate #4 select and Timeouts

5 min read

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 #

basic select
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.

timeout
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 #

cancellation pattern
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.

non-blocking
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.

non-blocking send
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 #

periodic
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.

event loop
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.

using nil channels
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.

dynamic case
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 vs deadline
// 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.

common pitfall
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.

reset pattern
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 #

cases picked at random
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.

priority 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 #

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:

  • select picks one ready case from multiple channels
  • time.After for a timeout case
  • close(done) + <-done pattern for cancellation signals
  • default for non-blocking send/receive
  • time.NewTicker + defer Stop() for periodic work
  • for { select { ... } } as the standard event loop
  • nil channels block forever — dynamic case disabling
  • time.After leak 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.

X