Sitemap

Understanding Select in Go: Multiple Channels, Timeouts, and Tickers

13 min readOct 23, 2026

--

As I explored concurrency in Go, I started to understand how goroutines execute independently, how channels allow them to communicate, and how a program collects results from concurrent tasks.

But another question came to mind: What happens when a program needs to wait for multiple events at the same time?

Imagine that one goroutine produces data every second, another produces data every two seconds, and an HTTP request might take longer than expected. How can a program respond to whichever event is ready without unnecessarily waiting for a particular channel?

This is where Go’s select statement becomes useful.

I wanted to understand more than just its syntax. I wanted to see how it behaves when channels produce data at different rates, how it can handle timeouts, and how the same idea applies to periodic tasks and real-world processes.

In this article, I will explore these concepts through small experiments and build a mental model of when and why to use select.

Press enter or click to view image in full size

Go version: 1.24.4

The examples use simulated delays where appropriate. Actual execution times and the ordering of concurrent events may vary.

1. Understanding the Problem

In my previous exploration of Go concurrency, I used goroutines to perform independent tasks and channels to collect their results.

Consider a program that receives values from two channels:

value1 := <-ch1
value2 := <-ch2

The program first waits for ch1 and then waits for ch2.

Suppose ch1 produces values every second, while ch2 produces values every two seconds. After receiving from ch1, the program might have to wait for ch2, even if another value from ch1 could be processed.

The issue is the fixed order of communication.

I needed a way to wait for either channel to become ready and handle whichever one could proceed.

Go provides the select statement for exactly this situation.

2. What Is the Select Statement?

The select statement lets a goroutine wait on multiple channel send or receive operations.

Here is a simple example:

select {
case message := <-ch1:
fmt.Println("Received from channel 1:", message)
case message := <-ch2:
fmt.Println("Received from channel 2:", message)
}

Think of each case as a possible communication event.

  • The first case receives a value from ch1.
  • The second case receives a value from ch2.

If neither operation is ready, the goroutine waits until at least one can proceed.

If multiple cases are ready simultaneously, Go chooses one of the ready cases pseudo-randomly. It does not guarantee that the channel with the earliest-arriving message will always be selected first.

I found it useful to remember three rules:

  1. Ready case: If a communication can proceed, its case is eligible to execute.
  2. No ready case: Without a default, the goroutine blocks.
  3. Multiple ready cases: Go chooses one of the ready cases.

A select can handle both receives and sends. It can also include a default case, which executes immediately if no communication case is ready.

With this basic understanding, I wanted to see what happens when two goroutines produce values at different speeds.

3. Experiment One: Receiving from Two Channels

For this experiment, I created two goroutines:

  • Worker 1 sends a value every second.
  • Worker 2 sends a value every two seconds.

Each worker sends its ID through its own channel.

Creating the workers

package main

import (
"fmt"
"time"
)

func worker(id int, delay time.Duration, ch chan<- int) {
for {
time.Sleep(delay)
ch <- id
}
}

func main() {
chans := []chan int{
make(chan int),
make(chan int),
}

go worker(1, time.Second, chans[0])
go worker(2, 2*time.Second, chans[1])

for i := 0; i < 12; i++ {
select {
case value := <-chans[0]:
fmt.Println("Received:", value)

case value := <-chans[1]:
fmt.Println("Received:", value)
}
}
}

The worker function sleeps for its assigned duration and then sends its ID through the channel.

Notice the parameter:

ch chan<- int

This is a send-only channel. The worker can send integers through it but cannot receive from it.

Both channels are unbuffered, so each send must synchronize with a receiver before it completes.

The main goroutine uses select to receive from either channel.

Understanding the output

A possible output begins like this:

Received: 1
Received: 2
Received: 1
Received: 1
Received: 2

The exact output order is not guaranteed. In particular, when both cases are ready, either can be selected.

The important observation is that the main goroutine does not have to decide in advance which channel it will read next.

What happens without select?

Consider this alternative:

for {
value1 := <-chans[0]
fmt.Println("Received:", value1)

value2 := <-chans[1]
fmt.Println("Received:", value2)
}

After receiving from the first channel, the main goroutine must wait for the second channel.

Even if another value is available on the first channel, the main goroutine cannot receive it until the second receive completes.

With select, the program can respond to whichever channel is ready.

This does not mean sequential receives are always wrong. They are perfectly appropriate when a particular order is required. The difference is that select is useful when multiple independent communication events can happen at different times.

My takeaway: select removes the need to commit to a fixed order when waiting for multiple channel operations.

4. Experiment Two: Handling a Timeout

Receiving from multiple channels is useful, but what happens when an operation takes too long?

Imagine that an HTTP request normally completes quickly but occasionally takes several seconds. If the application is willing to wait only three seconds, it needs a way to stop waiting after that deadline.

Go provides time.After for this purpose.

Understanding time.After

stopper := time.After(3 * time.Second)

This returns a channel that becomes ready after the specified duration.

We can use that channel alongside a results channel:

select {
case result := <-results:
fmt.Println("Received:", result)

case <-stopper:
fmt.Println("Operation timed out")
}

The program waits for two possible events:

  • A result becomes available.
  • The timeout expires.

Instead of repeatedly checking the clock, the program waits for either communication event.

A complete example

I used a simulated worker to make the behavior easy to observe.

package main

import (
"fmt"
"time"
)

func slowWorker(results chan<- string) {
time.Sleep(5 * time.Second)
results <- "Work completed"
}

func main() {
// A buffer allows the worker to send its result
// even if main has already handled the timeout.
results := make(chan string, 1)

go slowWorker(results)

stopper := time.After(3 * time.Second)

select {
case result := <-results:
fmt.Println(result)

case <-stopper:
fmt.Println("Operation timed out")
}
}

Expected output:

Operation timed out

The worker takes approximately five seconds, but the main goroutine waits for only three seconds.

I used a buffered results channel here for an important reason: if the worker finishes after main has handled the timeout, it can still place its result in the buffer instead of becoming stuck on an unbuffered send.

This prevents that particular blocked-send problem, although a production application may need a more complete cancellation strategy.

Applying the same idea to HTTP requests

Suppose I start several HTTP requests concurrently and send their results to a shared results channel.

I can use a single timeout for the entire collection period:

stopper := time.After(3 * time.Second)

for range urls {
select {
case result := <-results:
fmt.Printf("%s took %s\n",
result.URL, result.Latency)

case <-stopper:
fmt.Println("Collection timed out")
return
}
}

Here, urls is the list of requested URLs, and results is a channel carrying a result type with URL and Latency fields.

The workers must be started before this loop, and the results channel must be connected to those workers.

One detail matters: this is a shared deadline, not a three-second timeout for every individual result. Once the timer expires, its channel remains ready.

Timeout is not the same as cancellation

This distinction is important enough to remember separately.

A timeout determines how long the caller waits. It does not automatically stop the underlying operation.

For HTTP requests, I would generally use context.WithTimeout and pass the context to the request. For other long-running work, I would design the operation to respond to cancellation.

The goal is not just to stop waiting, but also to avoid unnecessary work and resources.

My takeaway: A timeout is simply another event that can participate in select. Cancellation is a separate responsibility.

5. Experiment Three: Performing Periodic Work with a Ticker

So far, I have used select to receive results and handle timeouts.

But what if a program needs to perform work repeatedly?

For example, a backend service might refresh cached data, clean up expired entries, or publish metrics at regular intervals.

Go provides time.NewTicker for this purpose.

Unlike time.After, which becomes ready after one duration, a ticker produces recurring events through its channel.

Creating a periodic task

package main

import (
"fmt"
"time"
)

func main() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()

stopper := time.After(10 * time.Second)

fmt.Println("Start")

loop:
for {
select {
case <-ticker.C:
fmt.Println("Tick")

case <-stopper:
break loop
}
}

fmt.Println("Finish")
}

The ticker produces events approximately every two seconds, while the timeout becomes ready after ten seconds.

A typical output is:

Start
Tick
Tick
Tick
Tick
Tick
Finish

The exact number of ticks can vary. If the ticker and timeout are both ready at the same time, Go may select either case.

Why use a labeled loop?

Notice this part:

loop:
for {
select {
case <-ticker.C:
fmt.Println("Tick")

case <-stopper:
break loop
}
}

An ordinary break inside the select would exit the select statement, not the surrounding for loop.

By writing break loop, I explicitly tell Go to exit the labeled loop.

I also used:

defer ticker.Stop()

This stops the ticker when main returns.

Applying this to a backend service

Imagine a service that refreshes a Redis cache every ten minutes.

Get Shivam Kumar’s stories in your inbox

Join Medium for free to get updates from this writer.

A simplified structure could look like this:

ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()

for {
select {
case <-ticker.C:
refreshCache()

case <-done:
return
}
}

Here, done is a channel used to signal that the service should stop.

The ticker determines when work should happen. The done channel determines when the loop should terminate.

If refreshCache can take a long time, I would also make that operation cancellation-aware where appropriate.

My takeaway: select can coordinate recurring events and shutdown signals in the same loop.

6. Experiment Four: Using Default for Nonblocking Communication

The next behavior I explored was the default case.

In a select statement, default executes immediately if none of the communication cases is ready.

Consider this example:

select {
case message := <-ch:
fmt.Println("Received:", message)

default:
fmt.Println("No message available")
}

If ch has no value ready to receive, the program does not block. It executes the default branch.

This is useful when I want to attempt a channel operation without waiting.

A nonblocking send

Imagine a service that produces events and sends them to another component for processing.

If the receiving channel cannot accept an event immediately, I might decide to drop the event rather than block the producer.

func sendOrDrop(ch chan<- []byte, data []byte) {
select {
case ch <- data:
// Event sent successfully.
default:
fmt.Printf(
"Overflow: dropped %d bytes\n",
len(data),
)
}
}

If the send can proceed immediately, it succeeds. Otherwise, the default case executes.

For an unbuffered channel, a receiver must be ready for the send to proceed. For a buffered channel, the send can proceed if buffer capacity is available.

This can be useful for best-effort logging or telemetry, where occasional data loss is an explicit design decision.

However, dropping data is not always acceptable. In a production system, I would consider whether to buffer events, apply backpressure, retry the operation, or use a durable queue.

Why default can cause a problem

Consider this loop:

for {
select {
case message := <-ch:
fmt.Println(message)

default:
// Nothing to process.
}
}

When no message is available, the loop immediately executes default and starts another iteration.

It does not wait for new data. It repeatedly checks whether communication is possible.

This is called busy waiting, and it can consume significant CPU time.

The solution is not to avoid default entirely. It is to use it only when nonblocking behavior is intentional.

My takeaway: default changes what happens when no communication is ready. It does not make an operation magically faster or guarantee that data will be delivered.

7. Experiment Five: Monitoring a Subprocess

The previous experiments focused on channels carrying values, timeout signals, and periodic events.

I also wanted to understand how the same pattern could coordinate a subprocess.

Imagine starting an external server process. The parent program needs to know whether the server has started successfully or whether an error has occurred.

One approach is to use a goroutine to monitor the process’s output and communicate its status through a channel.

The main goroutine can then wait for either the status message or a timeout.

Understanding the flow

Main goroutine
|
| Starts subprocess
|
v
Output-monitoring goroutine
|
| Sends status through a channel
v
Status channel --------+
|
Timeout channel -------+--> select
|
+----------+----------+
| |
Status received Timeout reached
| |
Handle result Handle timeout

The main goroutine does not need to read the subprocess output itself. Another goroutine can do that work and communicate the result.

A small simulation

Before working with an actual operating-system process, I used a simulated status channel to understand the coordination pattern.

package main

import (
"fmt"
"time"
)

func monitor(done chan<- string) {
// Simulate a process that starts after two seconds.
time.Sleep(2 * time.Second)
done <- "running"
}

func main() {
done := make(chan string, 1)

go monitor(done)

select {
case result := <-done:
if result == "running" {
fmt.Println("Server started successfully")
} else {
fmt.Println("Server failed:", result)
}

case <-time.After(20 * time.Second):
fmt.Println("Server startup timed out")
}
}

Expected output:

Server started successfully

This example does not launch a real subprocess. It simulates the status message that a monitoring goroutine might produce.

Applying the pattern to a real subprocess

A real implementation would use the os/exec package to start a command, read its output, and detect a meaningful startup message or an error.

The communication structure remains the same:

  1. Start the subprocess.
  2. Monitor its output in a goroutine.
  3. Send a status or error through a channel.
  4. Use select to wait for the status or a deadline.

There is one important limitation: a timeout in select does not automatically terminate the subprocess.

For production code, I would consider exec.CommandContext with a deadline or cancellation context, check errors from process startup, call Wait appropriately, and ensure the output-reading goroutine terminates.

My takeaway: The same select pattern can coordinate events from goroutines, timers, and external processes. What changes is the work performed by the components communicating through the channels.

8. What These Patterns Have in Common

After working through these experiments, I found that select is easier to understand when I focus on the events a goroutine needs to handle.

  1. Receive from multiple workers: Use multiple select cases to handle whichever result is ready.
  2. Stop waiting after a deadline: Use time.After to signal when a specified duration has elapsed.
  3. Perform periodic work: Use time.NewTicker to produce recurring timer events.
  4. Stop a background loop: Use a done channel to communicate a termination signal.
  5. Attempt a nonblocking send: Use the default case to avoid waiting when a send cannot proceed immediately.
  6. Monitor a subprocess: Use a status channel to communicate success or failure.

Each pattern uses the same fundamental idea: wait for one of several possible events, then respond to the selected event.

A results channel communicates that work has completed. A timeout channel communicates that a deadline has passed. A ticker communicates that a periodic event has occurred. A done channel communicates that the program should stop doing something.

The select statement lets one goroutine coordinate these events without needing to perform a blocking receive from each channel in a fixed order.

This is one reason channels are composable synchronization mechanisms.

9. Common Mistakes to Avoid

As I continue exploring Go concurrency, there are a few mistakes I want to avoid when using select.

1. Assuming select always chooses the earliest result

When multiple cases are ready, Go chooses among the ready cases pseudo-randomly.

If strict ordering is required, the program needs to implement that ordering explicitly.

2. Assuming a timeout cancels ongoing work

A timeout determines which event the current select handles. It does not automatically cancel the underlying operation.

Use cancellation-aware APIs and propagate cancellation where necessary.

3. Forgetting to stop a ticker

A ticker should be stopped when it is no longer needed. Using defer ticker.Stop() is convenient when the ticker's lifetime matches the surrounding function.

4. Adding default to every select loop

A default case can cause busy waiting if the loop repeatedly runs without any communication being ready.

If the intention is to wait for new events, a blocking select without default is often the right choice.

5. Forgetting that channel operations can block

A send or receive may wait indefinitely if no matching communication can occur.

Every concurrent operation should have a clear completion strategy, and any goroutine that can block should have a reasonable way to finish.

6. Closing a channel while another goroutine is still sending

Sending on a closed channel causes a panic. Closing an already closed channel also causes a panic.

The component responsible for producing values should generally close the channel once it knows that no further sends will occur.

These details matter because concurrency problems often arise not from the syntax itself, but from unclear communication and completion rules.

10. What I Took Away from These Experiments

The most useful insight for me was that select is not simply another control structure like if or for.

It provides a way to coordinate multiple communication events when their readiness is not known in advance.

The main concepts fit together as follows:

  1. Goroutines allow independent tasks to make progress.
  2. Channels provide communication and synchronization.
  3. Select allows a goroutine to wait for multiple possible channel operations.
  4. Timeouts allow a program to respond when a deadline expires.
  5. Tickers provide periodic events for recurring work.
  6. Default allows channel operations to be attempted without blocking.
  7. Cancellation and cleanup help ensure that concurrent work finishes safely.

I also found it useful to ask a few questions whenever I introduce a select into a program:

  • Which events should this goroutine respond to?
  • What should happen when each event occurs?
  • What happens if none of the communication cases is ready?
  • What happens if multiple cases are ready?
  • How will the goroutine and any work it started eventually finish?

Answering these questions helps me reason about the program’s behavior instead of focusing only on its syntax.

Final Thoughts

My biggest takeaway is that Go concurrency becomes more expressive when goroutines and channels are combined with the select statement.

Channels helped me understand how independent tasks communicate. select helped me understand how a goroutine can coordinate multiple communication events without committing to a fixed receive order.

The same idea applies to collecting results, handling timeouts, running periodic background tasks, preventing producers from blocking, and monitoring external processes.

At the same time, select does not remove the need to think carefully about cancellation, resource cleanup, and goroutine lifetimes.

Before adding a select statement, I now find it useful to think through three questions:

What events am I waiting for? What should happen when each event occurs? How does the operation eventually finish?

Those questions provide a practical starting point for writing concurrent Go programs that are easier to understand, test, and maintain.

Further Reading

#Go #Golang #Concurrency #Goroutines #Channels #SoftwareEngineering

--

--

Shivam Kumar
Shivam Kumar

Written by Shivam Kumar

Backend Developer | Golang • Node.js • PostgreSQL • Docker | LeetCode Knight | IIIT Dharwad,25