Skip to main content
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")
	}
}
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.

Performance Considerations

ApproachOverheadUse Case
time.After()LowOne-off timeouts
time.NewTimer()Very lowReusable timeouts
context.WithTimeout()LowComposable operations
Manual tickerMediumCustom timeout logic

Best Practices

  1. Buffer result channels - Prevent goroutine leaks on timeout
  2. Use context for cancellation - More composable than time.After
  3. Avoid time.After in loops - Use time.NewTimer instead
  4. Set reasonable timeouts - Not too short, not infinite
  5. Log timeout events - Important for debugging
  6. Clean up resources - Use defer to stop timers
  7. 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,
}

Build docs developers (and LLMs) love