Skip to Content
Course content

125: time.Sleep and Context-Based Timeouts

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I was digging through some old code the other day and found a common pattern that drives me crazy: using time.Sleep to "handle" potential delays in external API calls. It's a blunt instrument. If you tell a goroutine to sleep for five seconds, it's gone. It isn't listening to the world; it's just paused. In a production environment, that's a recipe for leaked goroutines and unresponsive services.

The blunt instrument approach

Let's look at a typical scenario. Suppose we're writing a function to fetch a user's profile from a slow legacy database. To simulate the lag, I'll use time.Sleep. Here is how I'd initially write this if I weren't thinking about timeouts:

package main

import (
	"fmt"
	"time"
)

func fetchUserProfile(userID int) string {
	// Simulate a very slow database query
	time.Sleep(5 * time.Second) 
	return fmt.Sprintf("Profile data for user %d", userID)
}

func main() {
	fmt.Println("Fetching profile...")
	profile := fetchUserProfile(123)
	fmt.Println(profile)
}

If I run this, it works—eventually. But here's the problem: the main function is completely blocked. If this were a web server and the database started lagging, every single incoming request would just sit there for five seconds. If the user refreshes their browser or cancels the request, our server is still stuck in that time.Sleep, wasting resources.

Trying to "force" a timeout

You might think, "Why not just run the fetch in a goroutine and use a timer in the main thread?" I tried that once. You end up with a "race" where you might ignore the result of the function, but the fetchUserProfile goroutine is still sleeping in the background. It doesn't know it's been abandoned. In Go, we don't kill goroutines from the outside; we tell them to stop themselves. That's where context comes in.

Making the function responsive

To fix this, I need to pass a context.Context into the function. Instead of a blind sleep, I'll use a select statement. This allows the function to wait for the "work" to finish OR for the context to tell it to give up.

Let's rewrite the fetch function. I'll replace time.Sleep with a channel that signals when the "work" is done, and I'll listen to ctx.Done().

package main

import (
	"context"
	"fmt"
	"time"
)

func fetchUserProfile(ctx context.Context, userID int) (string, error) {
	// We create a channel to simulate the completion of the work
	done := make(chan string)

	go func() {
		// Simulate the slow work here
		time.Sleep(5 * time.Second)
		done <- fmt.Sprintf("Profile data for user %d", userID)
	}()

	// This is the magic part
	select {
	case res := <-done:
		return res, nil
	case <-ctx.Done():
		// The context timed out or was cancelled
		return "", ctx.Err()
	}
}

func main() {
	// Create a context that automatically cancels after 2 seconds
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel() // Always call cancel to release resources!

	fmt.Println("Fetching profile with a 2s timeout...")
	profile, err := fetchUserProfile(ctx, 123)
	if err != nil {
		fmt.Printf("Error: %v\n", err)
		return
	}
	fmt.Println(profile)
}

What actually happened here?

When I run this version, it doesn't wait five seconds. It hits the 2-second limit and immediately prints Error: context deadline exceeded.

I've shifted the control from the function itself to the caller. The main function now decides how long it's willing to wait. Inside fetchUserProfile, the select block is essentially a race. Either the done channel gets a value (success), or the ctx.Done() channel closes (timeout/cancellation). Whichever happens first wins.

One detail you can't miss: defer cancel(). Even if the function finishes successfully in 1 second, the context's timer is still running in the background until it hits 2 seconds. Calling cancel() stops that timer immediately. If you forget this in a high-traffic loop, you'll create a memory leak that's a nightmare to debug.




📋 Practical Task

Exercise: Implementing a Timeout for a Legacy Database Query

You are integrating with a legacy reporting system that occasionally "hangs" for 30 seconds or more. Your application cannot afford to wait that long; it must return an error to the user if the report isn't ready within 3 seconds.

Your Task:

  • Create a function generateReport(ctx context.Context, reportID string) (string, error).
  • Inside the function, simulate a variable delay using a goroutine and time.Sleep. To make your testing interesting, use a random sleep duration between 1 and 5 seconds (you can use math/rand or just hardcode a value to test both success and failure).
  • Use a select block to handle the competition between the report completion and the ctx.Done() channel.
  • In main, initialize a context with a 3-second timeout using context.WithTimeout.
  • Ensure you call the cancel function using defer.
  • Print the resulting report string on success, or the context error on timeout.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.