The race between work and time
You are building a service that calls a third-party API. The API usually responds in 200 milliseconds. Once a month, it hangs for five minutes. Your server handles 100 concurrent requests. That one hanging request holds a goroutine open. Another hangs. Then another. Soon you have 100 goroutines blocked, memory fills up, and the whole service grinds to a halt. You need a way to say, "I'll wait for the answer, but only for one second. If it takes longer, I'm moving on."
Go solves this with channels and the select statement. Channels act like pipes where data flows. The select statement acts like a traffic cop standing at multiple pipes, ready to process the first one that sends something. time.After creates a special pipe that sends a signal exactly when a timer expires. Combine them, and you get a race between your operation and the clock.
The minimal pattern
Here's the simplest pattern: spawn a goroutine to do work, set up a select block, and race the result against a timer.
package main
import (
"fmt"
"time"
)
func main() {
// Channel to receive the result from the background task.
result := make(chan string)
// Start the work in a separate goroutine so it can run concurrently.
go func() {
// Simulate a slow operation that takes two seconds.
time.Sleep(2 * time.Second)
// Send the result back through the channel.
result <- "done"
}()
// Select waits for the first available case.
// If the result arrives first, print it.
// If the timer fires first, print the timeout message.
select {
case res := <-result:
fmt.Println("Success:", res)
case <-time.After(1 * time.Second):
fmt.Println("Timeout occurred")
}
}
What happens at runtime
When the program runs, main creates the channel and launches the goroutine. The goroutine sleeps for two seconds. Meanwhile, main hits the select statement. select blocks until one of its cases can proceed. It sees two cases. The first case tries to read from result. Nothing is there yet, so it waits. The second case tries to read from the channel returned by time.After. time.After starts a timer in the background and returns a channel that will send a value after one second.
After one second, the timer fires, sending a value into that channel. select sees activity on the timer case. It executes that branch and prints "Timeout occurred". The program exits. The goroutine is still sleeping. It will eventually send "done" to the result channel, but no one is listening anymore. The send blocks forever, and the goroutine leaks.
Select blocks until someone speaks. Then it moves on.
How select picks a winner
If multiple cases are ready at the same time, select picks one at random. This is a deliberate design choice for fairness. If you have a fast channel and a slow channel, and both send at the exact same instant, select does not guarantee which one wins. This prevents starvation where one fast channel hogs all the attention. It also means you cannot rely on ordering inside a select block. If order matters, structure your code to avoid racing channels that might fire simultaneously.
Pitfalls and compiler errors
The most common mistake is forgetting that the goroutine keeps running after the timeout. If you fire off a goroutine for every request and timeout frequently, you accumulate goroutines that never finish. This eats memory and file descriptors. The compiler won't stop you. You just get a slow leak. When a goroutine leaks, your process memory grows slowly. You might not notice until the server restarts or hits a limit. Use pprof to inspect goroutines. If you see many goroutines stuck in chan send, you have an unbuffered channel where the receiver has gone away. This is the signature of a timeout leak.
The compiler catches structural errors in select. If you write a select with no cases, the compiler rejects the program with select with no cases. If you try to receive from a variable that isn't a channel, the compiler complains with invalid operation: cannot receive from non-channel. If you forget to import the time package, you get undefined: time. These errors are immediate and clear. The runtime leaks are the harder part.
A timeout that leaks a goroutine is worse than no timeout at all.
Realistic code with context
Real code uses context.Context to propagate timeouts. The select pattern remains the same, but you listen on ctx.Done() instead of time.After. This lets the caller control the deadline and allows cancellation to flow through multiple layers. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
Here's a function that fetches data and respects a context deadline. The channel is buffered to one so the goroutine can send its result and exit even if the caller has already timed out.
// fetchData performs work and respects the context deadline.
// It returns the result or an error if the context is cancelled.
func fetchData(ctx context.Context) (string, error) {
// Buffered channel so the goroutine can send and exit even if the caller times out.
result := make(chan string, 1)
go func() {
// Simulate slow work.
time.Sleep(2 * time.Second)
// Send result. The buffer ensures this send never blocks.
result <- "data"
}()
select {
case res := <-result:
return res, nil
case <-ctx.Done():
// Context was cancelled or deadline exceeded.
// Return the error from the context to explain why.
return "", ctx.Err()
}
}
The caller creates the context and passes it down.
func main() {
// Create a context with a one-second deadline.
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
// Defer cancel to release resources associated with the context.
defer cancel()
res, err := fetchData(ctx)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", res)
}
The buffer is the key detail here. Without the buffer, the goroutine would block on the send if the timeout fires first. The buffer holds the value, the send succeeds, and the goroutine returns cleanly. The value is discarded because no one reads it, but the goroutine is safe. This pattern prevents leaks in production systems.
Context is plumbing. Run it through every long-lived call site.
Convention asides
gofmt aligns select cases automatically. The tool ensures the code is readable without you fighting indentation. Most editors run gofmt on save. Trust the formatter.
time.After is not cancellable. If you call time.After(10 * time.Hour), the timer runs for 10 hours even if you return early. context.WithTimeout allows you to call cancel() to stop the timer early. This saves resources. Use context for anything that might be cancelled or has a dynamic deadline.
Decision matrix
Use time.After with select when you need a quick timeout in a script or test and don't need to propagate cancellation to other layers.
Use context.WithTimeout with select when you are building a library or service function that must respect deadlines and allow the caller to cancel the work.
Use a buffered channel in the goroutine when the background work might finish after the timeout, ensuring the goroutine can send its result and exit without blocking.
Use select with a default case when you need a non-blocking check to see if a value is available immediately, rather than waiting for a timeout.
Use sync.WaitGroup when you need to wait for multiple goroutines to finish and don't need a timeout mechanism.
Pick the tool that matches the scope. Scripts get time.After. Services get context.