Timeouts are important for programs that connect to external resources or need to bound execution time. Go makes implementing timeouts easy and elegant using channels and select.
Basic Timeout Pattern
package main
import (
"fmt"
"time"
)
func main() {
c1 := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
c1 <- "result 1"
}()
// Timeout after 1 second
select {
case res := <-c1:
fmt.Println(res)
case <-time.After(1 * time.Second):
fmt.Println("timeout 1")
}
}
time.After() returns a channel that sends a value after the specified duration, making it perfect for timeout patterns with select.
Successful vs Timeout
// Operation that completes in time
c2 := make(chan string, 1)
go func() {
time.Sleep(2 * time.Second)
c2 <- "result 2"
}()
select {
case res := <-c2:
fmt.Println(res) // Prints "result 2"
case <-time.After(3 * time.Second):
fmt.Println("timeout 2") // Not reached
}
Why Buffer the Channel?
Notice the example uses buffered channels:
c1 := make(chan string, 1) // Buffer of 1
Buffering prevents goroutine leaks. If the timeout occurs, the goroutine can still send without blocking, allowing it to exit cleanly.
Without Buffer (Goroutine Leak)
// BAD: Potential goroutine leak
ch := make(chan string) // Unbuffered!
go func() {
result := slowOperation()
ch <- result // Blocks forever if timeout occurred!
}()
select {
case r := <-ch:
return r
case <-time.After(1 * time.Second):
return "timeout" // Goroutine still blocked on send!
}
With Buffer (Clean Exit)
// GOOD: Goroutine can exit
ch := make(chan string, 1) // Buffered
go func() {
result := slowOperation()
ch <- result // Never blocks, goroutine exits
}()
select {
case r := <-ch:
return r
case <-time.After(1 * time.Second):
return "timeout"
}
Practical Timeout Patterns
HTTP Request with Timeout
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
result := make(chan string, 1)
errors := make(chan error, 1)
go func() {
resp, err := http.Get(url)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result <- string(body)
}()
select {
case data := <-result:
return data, nil
case err := <-errors:
return "", err
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Database Query with Timeout
func queryWithTimeout(db *sql.DB, query string, timeout time.Duration) ([]Row, error) {
results := make(chan []Row, 1)
errors := make(chan error, 1)
go func() {
rows, err := db.Query(query)
if err != nil {
errors <- err
return
}
results <- processRows(rows)
}()
select {
case data := <-results:
return data, nil
case err := <-errors:
return nil, err
case <-time.After(timeout):
return nil, errors.New("query timeout")
}
}
Context-Based Timeout (Recommended)
func fetchWithContext(ctx context.Context, url string) (string, error) {
result := make(chan string, 1)
errors := make(chan error, 1)
go func() {
resp, err := http.Get(url)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result <- string(body)
}()
select {
case data := <-result:
return data, nil
case err := <-errors:
return "", err
case <-ctx.Done():
return "", ctx.Err()
}
}
// Usage:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
data, err := fetchWithContext(ctx, "https://api.example.com")
For production code, prefer context.Context for timeouts and cancellation. It’s more composable and integrates with Go’s standard library.
Multiple Operations with Individual Timeouts
func orchestrate() {
// Operation 1: 1 second timeout
ch1 := make(chan string, 1)
go operation1(ch1)
var result1 string
select {
case result1 = <-ch1:
fmt.Println("Got result1:", result1)
case <-time.After(1 * time.Second):
fmt.Println("Operation 1 timeout")
return
}
// Operation 2: 2 second timeout
ch2 := make(chan string, 1)
go operation2(ch2, result1)
select {
case result2 := <-ch2:
fmt.Println("Got result2:", result2)
case <-time.After(2 * time.Second):
fmt.Println("Operation 2 timeout")
}
}
Global Timeout for Multiple Operations
func orchestrateWithGlobalTimeout() error {
timeout := time.After(5 * time.Second)
// Operation 1
ch1 := make(chan string, 1)
go operation1(ch1)
select {
case result1 := <-ch1:
fmt.Println("Result 1:", result1)
case <-timeout:
return errors.New("global timeout")
}
// Operation 2
ch2 := make(chan string, 1)
go operation2(ch2)
select {
case result2 := <-ch2:
fmt.Println("Result 2:", result2)
case <-timeout:
return errors.New("global timeout")
}
return nil
}
Timeout with Retry
func fetchWithRetry(url string, attempts int, timeout time.Duration) (string, error) {
for i := 0; i < attempts; i++ {
result := make(chan string, 1)
errors := make(chan error, 1)
go func() {
data, err := http.Get(url)
if err != nil {
errors <- err
return
}
result <- processResponse(data)
}()
select {
case data := <-result:
return data, nil
case err := <-errors:
if i == attempts-1 {
return "", err
}
time.Sleep(time.Second * time.Duration(i+1)) // Backoff
case <-time.After(timeout):
if i == attempts-1 {
return "", errors.New("max retries exceeded")
}
}
}
return "", errors.New("unreachable")
}
Cancellable Timeout
type CancellableTimeout struct {
timer *time.Timer
}
func NewCancellableTimeout(d time.Duration) *CancellableTimeout {
return &CancellableTimeout{
timer: time.NewTimer(d),
}
}
func (ct *CancellableTimeout) C() <-chan time.Time {
return ct.timer.C
}
func (ct *CancellableTimeout) Cancel() {
ct.timer.Stop()
}
// Usage:
func worker() {
timeout := NewCancellableTimeout(5 * time.Second)
defer timeout.Cancel()
select {
case result := <-work():
processResult(result)
case <-timeout.C():
fmt.Println("timeout")
}
}
Common Mistakes
time.After Memory Leak
// BAD: Creates new timer every iteration
for {
select {
case msg := <-messages:
process(msg)
case <-time.After(1 * time.Second): // LEAK!
timeout()
}
}
// GOOD: Reuse timer
timer := time.NewTimer(1 * time.Second)
defer timer.Stop()
for {
timer.Reset(1 * time.Second)
select {
case msg := <-messages:
process(msg)
case <-timer.C:
timeout()
}
}
time.After() creates a timer that won’t be garbage collected until it fires. In a loop, this creates a memory leak.
Not Buffering Result Channel
See “Why Buffer the Channel?” section above.
| Approach | Overhead | Use Case |
|---|
time.After() | Low | One-off timeouts |
time.NewTimer() | Very low | Reusable timeouts |
context.WithTimeout() | Low | Composable operations |
| Manual ticker | Medium | Custom timeout logic |
Best Practices
- Buffer result channels - Prevent goroutine leaks on timeout
- Use context for cancellation - More composable than time.After
- Avoid time.After in loops - Use time.NewTimer instead
- Set reasonable timeouts - Not too short, not infinite
- Log timeout events - Important for debugging
- Clean up resources - Use defer to stop timers
- Return errors, don’t panic - Let callers handle timeouts
Timeout Configuration Patterns
type Config struct {
ConnectTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
var DefaultConfig = Config{
ConnectTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 90 * time.Second,
}