In this post, we’ll learn about Go’s context package, and more specifically, how we can use it to improve our applications performance.

banner image

The main purpose of using Context is to manage long-running processes (like an HTTP request, a database call, or a network call) in a way that doesn’t waste resources.

If used correctly, the context package can help you:

  1. Cancel long running processes that are no longer needed
  2. Pass around request-scoped data and cancellation signals between function calls
  3. Set deadlines for processes to complete

When Do We Use Context?

The main use of a context instance is to pass common scoped data within our application. For example:

  • Request IDs for function calls and goroutines that are part of the same HTTP request
  • Errors encountered when fetching data from a database
  • Cancellation signals created when performing async operations using goroutines

context refers to common scoped data within goroutines or function calls

Using the Context type is the idiomatic way to pass information across these kind of operations, such as:

  1. Cancellation and deadline signals to terminate the operation
  2. Miscellaneous data required at every function call invoked by the operation

Creating a New Context

We can create a new context using the context.Background() function:

ctx := context.Background()

This function returns a new context instance that is empty and has no values associated with it.

In many cases, we won’t be creating a new context instance, but rather using an existing one.

For example, when we’re handling an HTTP request, we can use the http.Request.Context() function to get the request’s context:

// Create an HTTP server that listens on port 8000
http.ListenAndServe(":8010", 
  http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Get the request's context
    ctx := r.Context()
    // ...
}))

Creating a Derived Context

When you receive a context from an external source, you can add your own values and cancellation signals to it by creating a derived context.

We can do this by using a decorator function like context.WithValue, context.WithCancel, or context.WithDeadline:

derived context

Each of these decorators has different effects on the context instance. Let’s take a look at each of them.

Context Cancellation Signals

One of the most common use cases of the context package is to propagate cancellation signals across function calls.

Why Do We Need Cancellation?

In short, we need cancellation to prevent our system from doing unnecessary work.

Consider the common situation of an HTTP server making a call to a database, and returning the queried data to the client:

client server model diagram

The timing diagram, if everything worked perfectly, would look like this:

timing diagram with all events finishing

But, what would happen if the client cancelled the request in the middle? This could happen if, for example, the client closed their browser mid-request.

Without cancellation, the application server and database would continue to do their work, even though the result of that work would be wasted:

timing diagram with http request cancelled, and other processes still taking place

Ideally, we would want all downstream components of a process to halt, if we know that the process (in this example, the HTTP request) halted:

timing diagram with all processes cancelling once HTTP request is cancelled

Now that we know why we need cancellation, let’s get into how you can implement it in Go.

Because “cancellation” is highly contextual to the operation being performed, the best way to implement it is through context.

There are two sides to context cancellation:

  1. Listening for the cancellation signal
  2. Emitting the cancellation signal

Listening For Cancellation Signals

The Context type provides a Done() method. This returns a channel that receives an empty struct{} type every time the context receives a cancellation signal.

So, to listen for a cancellation signal, we need to wait on <- ctx.Done().

For example, lets consider an HTTP server that takes two seconds to process an event. If the request gets cancelled before that, we want to return immediately:

func main() {
	// Create an HTTP server that listens on port 8000
	http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		// This prints to STDOUT to show that processing has started
		fmt.Fprint(os.Stdout, "processing request\n")
		// We use `select` to execute a piece of code depending on which
		// channel receives a message first
		select {
		case <-time.After(2 * time.Second):
			// If we receive a message after 2 seconds
			// that means the request has been processed
			// We then write this as the response
			w.Write([]byte("request processed"))
		case <-ctx.Done():
			// If the request gets cancelled, log it
			// to STDERR
			fmt.Fprint(os.Stderr, "request cancelled\n")
		}
	}))
}

You can view the source code for all the examples on Github

You can test this by running the server and opening localhost:8000 on your browser. If you close your browser before 2 seconds, you should see “request cancelled” printed on the terminal window.

Emitting a Cancellation Signal

If you have an operation that could be cancelled, you will have to emit a cancellation signal through the context.

This can be done using the WithCancel function in the context package, which returns a context object, and a function.

ctx, fn := context.WithCancel(ctx)

This function takes no arguments, and does not return anything, and is called when you want to cancel the context.

Consider the case of 2 dependent operations. Here, “dependent” means if one fails, it doesn’t make sense for the other to complete. If we get to know early on that one of the operations failed, we would like to cancel all dependent operations.

func operation1(ctx context.Context) error {
	// Let's assume that this operation failed for some reason
	// We use time.Sleep to simulate a resource intensive operation
	time.Sleep(100 * time.Millisecond)
	return errors.New("failed")
}

func operation2(ctx context.Context) {
	// We use a similar pattern to the HTTP server
	// that we saw in the earlier example
	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Println("done")
	case <-ctx.Done():
		fmt.Println("halted operation2")
	}
}

func main() {
	// Create a new context
	ctx := context.Background()
	// Create a new context, with its cancellation function
	// from the original context
	ctx, cancel := context.WithCancel(ctx)

	// Run two operations: one in a different go routine
	go func() {
		err := operation1(ctx)
		// If this operation returns an error
		// cancel all operations using this context
		if err != nil {
			cancel()
		}
	}()

	// Run operation2 with the same context we use for operation1
	operation2(ctx)
}

Output (full code):

halted operation2

Cancellation Signals with Causes

In the previous example, calling the cancel() function did not provide any information about why the context was cancelled. There are some cases where you might want to know the reason for cancellation.

For example, consider that you have a long running operation that is dependent on a database call. If the database call fails, you want know that the operation was cancelled because of the database failure, and not because of some other reason.

In these cases, we can use the context.WithCancelCause instead. This function returns a context object, and a function that takes an error as an argument.

Let’s see the same example as before, but with the WithCancelCause function:

func operation1(ctx context.Context) error {
	time.Sleep(100 * time.Millisecond)
	return errors.New("failed")
}

func operation2(ctx context.Context) {
	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Println("done")
	case <-ctx.Done():
    // We can get the error from the context
    err := context.Cause(ctx)
		fmt.Println("halted operation2 due to error: ", err)
	}
}

func main() {
	ctx := context.Background()
	ctx, cancel := context.WithCancelCause(ctx)

	go func() {
		err := operation1(ctx)
		if err != nil {
      // this time, we pass in the error as an argument
			cancel(err)
		}
	}()

	// Run operation2 with the same context we use for operation1
	operation2(ctx)
}

Output (full code):

halted operation2 due to error:  failed

Let’s summarize the error propagation pattern in this example:

  1. The context.WithCancelCause gives us the cancel function, which we can call with an error.
  2. Once we encounter and error, we call the cancel function with the error as an argument.
  3. Now that the context is cancelled, the ctx.Done() channel will receive a message.
  4. We can get the error from the context using the context.Cause function.

cancel with cause

Context Deadlines

If we want to set a deadline for a process to complete, we should use time based cancellation.

The functions are almost the same as the previous example, with a few additions:

// The context will be cancelled after 3 seconds
// If it needs to be cancelled earlier, the `cancel` function can
// be used, like before
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)

// Setting a context deadline is similar to setting a timeout, except
// you specify a time when you want the context to cancel, rather than a duration.
// Here, the context will be cancelled on 2009-11-10 23:00:00
ctx, cancel := context.WithDeadline(ctx, time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC))

For example, consider making an HTTP API call to an external service. If the service takes too long, it’s better to fail early and cancel the request:

func main() {
	// Create a new context
	// With a deadline of 100 milliseconds
	ctx := context.Background()
	ctx, _ = context.WithTimeout(ctx, 100*time.Millisecond)

	// Make a request, that will call the google homepage
	req, _ := http.NewRequest(http.MethodGet, "http://google.com", nil)
	// Associate the cancellable context we just created to the request
	req = req.WithContext(ctx)

	// Create a new HTTP client and execute the request
	client := &http.Client{}
	res, err := client.Do(req)
	// If the request failed, log to STDOUT
	if err != nil {
		fmt.Println("Request failed:", err)
		return
	}
	// Print the status code if the request succeeds
	fmt.Println("Response received, status code:", res.StatusCode)
}

Full code

Based on how fast the google homepage responds to your request, you will receive:

Response received, status code: 200

or

Request failed: Get http://google.com: context deadline exceeded

You can play around with the timeout to achieve both of the above results.

Is this code safe to run?
func doSomething() {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	someArg := "loremipsum"
	go doSomethingElse(ctx, someArg)
}

Would it be safe to execute the doSomething function?

When we execute doSomething, we create a context and defer its cancellation, which means the context will cancel after doSomething finishes execution.

We pass this same context to the doSomethingElse function, which may rely on the context provided to it. Since doSomethingElse is executed in a different goroutine, its likely that the context will cancel before doSomethingElse finishes it’s execution.

doSomething cancelling doSomethingElse

Unless this is explicitly what you want, you should always create a new context when running a function in a different goroutine, like so:

func doSomething() {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	someArg := "loremipsum"
  // create a new context for the goroutine
	go doSomethingElse(context.Background(), someArg)
}

Context Values

You can use the context variable to propagate request scoped values that are common across an operation. This is the more idiomatic alternative to just passing them around as arguments throughout your function calls.

Some example of values that you might want to propagate using the context variable are:

  1. Request IDs, so that you can trace the execution of a request across multiple function calls.
  2. Errors encountered when fetching data from a database
  3. Authentication tokens and user information

We can implement the same functionality using the context.WithValue decorator function:

// we need to set a key that tells us where the data is stored
const keyID = "id"

func main() {
	// create a request ID as a random number
  rand.Seed(time.Now().Unix())
  requestID := rand.Intn(1000)

  // create a new context variable with a key value pair
	ctx := context.WithValue(context.Background(), keyID, requestID)
	operation1(ctx)
}

func operation1(ctx context.Context) {
	// do some work

	// we can get the value from the context by passing in the key
	log.Println("operation1 for id:", ctx.Value(keyID), " completed")
	operation2(ctx)
}

func operation2(ctx context.Context) {
	// do some work

	// this way, the same ID is passed from one function call to the next
	log.Println("operation2 for id:", ctx.Value(keyID), " completed")
}

Output (full code):

2023/07/05 23:13:50 operation1 for id: 8151872133066627850  completed
2023/07/05 23:13:50 operation2 for id: 8151872133066627850  completed

Here, we’re creating a new context variable in the main function and a key value pair associated with it. The value can then be used by the successive function calls to obtain contextual information.

main creates a new context which is passed to other functions

Using the context variable to pass down operation-scoped information is useful for a number of reasons:

  1. It is thread safe: You can’t modify the value of a context key once it has been set. The only way set another value for a given key is to create another context variable using context.WithValue
  2. It is conventional: The context package is used throughout Go’s official libraries and applications to convey operation-scoped data. Other developers and libraries generally play nicely with this pattern.

Gotchas and Caveats

Although context cancellation in Go is a versatile tool, there are a few things that you should keep in mind before proceeding. The most important of which, is that a context can only be cancelled once.

If there are multiple errors that you would want to propagate in the same operation, then using context cancellation may not the best option.

The most idiomatic way to use cancellation is when you actually want to cancel something, and not just notify downstream processes that an error has occurred.

Another important caveat has to do with wrapping the same context multiple times.

Wrapping an already cancellable context with WithTimeout or WithCancel will enable multiple locations in your code in which your context could be cancelled, and this should be avoided.

The source code for all the above examples can be found on Github