Go Context Package: Mastering Request Cancellation in Go

Picture this: your application is juggling thousands of concurrent requests. A few of them start dragging their feet, eating up memory and CPU cycles while your users wait. How do you stop these runaway operations without taking down the whole server? That’s exactly what the Go context package was built for.

This guide walks you through the Go context package and shows you how to use it for request cancellation, timeouts, and passing request-scoped data. You’ll see real code examples, learn a few patterns worth following, and pick up on mistakes that trip up even experienced Go developers. By the end, you’ll know how to write Go code that handles messy, real-world request lifecycles without leaking goroutines or hanging forever.

What Is the Go Context Package?

The context package, part of the Go standard library since Go 1.7, gives you a way to carry deadlines, cancellation signals, and request-scoped values across function calls and even across network boundaries. If you’ve written any concurrent Go code that talks to databases, HTTP clients, or other goroutines, you’ve probably already bumped into it.

It’s a small package, but it solves a big problem: how do you tell a chain of function calls, “Hey, stop what you’re doing”?

Why Bother With Context?

Without context, canceling work in Go gets messy fast. You end up passing custom cancellation channels around, writing your own timeout logic, or – worse – just letting goroutines run to completion even after nobody cares about the result anymore. That’s how you leak goroutines and burn through memory.

Context fixes this by giving every function in the call chain a shared, standard signal to check. If the parent operation gets canceled or times out, every downstream function can find out about it and stop cleanly.

The Building Blocks

  • Context values: key-value pairs you can attach to a context and read further down the call chain (useful for things like request IDs or auth tokens).
  • Cancellation signals: a channel-based mechanism that tells goroutines to stop working.
  • Deadlines and timeouts: hard limits on how long an operation is allowed to run before it gets canceled automatically.

According to the Go blog post on concurrency patterns, Google introduced context internally years before open-sourcing it, specifically to solve cancellation problems across their massive distributed systems. That’s a good sign it’s built for real production use, not just toy examples.

Setting Up Your Environment

Before we get into code, make sure Go is installed. Head to the official Go download page, grab the installer for your OS, and run it.

Once that’s done, confirm everything works by running this in your terminal:

go version

You should see something like go version go1.22.0 darwin/arm64. If you get a “command not found” error, double check your PATH environment variable includes Go’s bin directory.

Creating Your First Context

Every context in your program starts from a root. Go gives you two ways to create one: context.Background() and context.TODO().

package main

import (
	"context"
	"fmt"
)

func main() {
	// context.Background() returns an empty, non-nil context.
	// Use it at the top level of your program, like in main() or in tests.
	ctx := context.Background()
	fmt.Println("Context created successfully:", ctx)
}
Code language: JavaScript (javascript)

Use context.Background() when you know you need a context but there isn’t a parent one yet – typically at the entry point of your application. Use context.TODO() as a placeholder when you’re not yet sure which context to use, usually while you’re still writing or refactoring code. Just don’t forget to replace it before shipping to production.

Adding Cancellation to a Context

Here’s where things get useful. context.WithCancel() returns a derived context along with a cancel function. Call that function, and every goroutine listening on ctx.Done() gets notified immediately.

package main

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

func main() {
	// Create a cancellable context derived from the background context
	ctx, cancel := context.WithCancel(context.Background())

	// Run some work in a separate goroutine
	go func() {
		select {
		case <-time.After(2 * time.Second):
			fmt.Println("Work completed")
		case <-ctx.Done():
			// ctx.Err() tells you why the context was canceled
			fmt.Println("Work cancelled:", ctx.Err())
		}
	}()

	// Simulate deciding to cancel after 1 second
	time.Sleep(1 * time.Second)
	cancel()

	// Give the goroutine time to react before main() exits
	time.Sleep(2 * time.Second)
}
Code language: PHP (php)

Run this, and you’ll see “Work cancelled: context canceled” printed after roughly one second, instead of waiting the full two seconds for the fake work to finish. That’s the whole point – you get to stop things early instead of waiting them out.

Handling Deadlines and Timeouts

Cancellation is great when you decide, at runtime, that something needs to stop. But often you know upfront how long an operation should be allowed to run. That’s what context.WithDeadline() and context.WithTimeout() are for.

WithTimeout() is really just a convenience wrapper around WithDeadline() – it calculates the deadline for you based on a duration.

package main

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

func main() {
	// This context will automatically cancel itself after 1 second
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel() // always call cancel to release resources, even if the timeout fires first

	select {
	case <-time.After(2 * time.Second):
		fmt.Println("Work completed")
	case <-ctx.Done():
		fmt.Println("Work timed out:", ctx.Err())
	}
}
Code language: PHP (php)

Notice the defer cancel() line. Even when a timeout fires on its own, you should still call cancel() to free up the resources associated with the context. Skipping this is a common source of subtle memory leaks in long-running Go services.

Guidelines for Using Context Effectively

A few habits will save you a lot of debugging time down the road:

  1. Pass context as the first parameter. By convention, Go functions that accept a context take it as the first argument, usually named ctx. This signals to other developers that the function can be canceled or has a timeout.
  2. Don’t store contexts in structs. Pass them explicitly through function calls instead. Storing a context in a struct field makes it stale and disconnected from the actual request lifecycle.
  3. Start with context.Background() at the top level. Your main() function, top-level HTTP handlers, or test functions are the right places to create a root context.
  4. Treat context.TODO() as temporary. It’s fine while prototyping, but replace it with a real context before merging code.
  5. Always call the cancel function, even if you’re confident the context will expire naturally. It’s cheap insurance against leaked resources.

Building a Cancellable API Handler

Let’s put this together with something closer to a real-world scenario: an HTTP handler that cancels slow requests instead of letting them hang indefinitely.

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func handler(w http.ResponseWriter, r *http.Request) {
	// Derive a timeout context from the incoming request's context.
	// r.Context() is automatically canceled if the client disconnects.
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	select {
	case <-time.After(10 * time.Second):
		// This branch simulates slow work that takes too long
		fmt.Fprintln(w, "Work completed")
	case <-ctx.Done():
		// This fires first because our timeout (5s) is shorter than the fake work (10s)
		http.Error(w, "Request cancelled", http.StatusGatewayTimeout)
	}
}

func main() {
	http.HandleFunc("/", handler)
	fmt.Println("Server starting on port 8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		fmt.Println("Server failed to start:", err)
	}
}
Code language: PHP (php)

A couple of details worth calling out: we derive our timeout context from r.Context() rather than context.Background(). Why does that matter? Because r.Context() is tied to the actual HTTP request. If the client closes the connection early, Go automatically cancels that context, and our handler stops working immediately instead of continuing to burn CPU on a request nobody’s waiting for anymore.

Testing the Server

Start the server with go run main.go, then hit http://localhost:8080 in your browser or with curl. Since our simulated work takes 10 seconds but the timeout is set to 5, you’ll get a 504 Gateway Timeout response after about 5 seconds instead of waiting the full 10.

Try adjusting the timeout value and the simulated work duration to see how the behavior changes. It’s a good way to build intuition for how these two settings interact in a real service.

Common Pitfalls to Watch For

  • Forgetting to call cancel(): Every context created with WithCancel, WithTimeout, or WithDeadline should have its cancel function called, typically with defer. Skipping this leaks memory over time.
  • Passing nil instead of a context: Some codebases still do this out of habit. Use context.TODO() if you genuinely don’t have a context yet.
  • Overusing context values: Context values are handy for things like request IDs or tracing metadata, but they’re not a replacement for normal function parameters. Stuffing business logic data into context values makes code harder to follow.
  • Ignoring ctx.Err(): When a context is done, ctx.Err() tells you whether it was canceled or timed out. Checking this can help with better logging and debugging.

Wrapping Up

The Go context package might look small, but it solves a real problem that shows up constantly in concurrent programs: how do you stop work cleanly when nobody needs the result anymore? Once you get comfortable with WithCancel, WithTimeout, and passing context through your call chain, you’ll find it becomes second nature.

Next Steps

  • Try swapping context.WithTimeout() for context.WithDeadline() in the handler example and see how the code changes.
  • Read the Go Blog’s original post on context for more background on why the package was designed this way.
  • Check out our guide on Go Error Handling to see how context and error handling often work together in real applications.
  • Got a context-related bug story? Share it in the comments – there’s a good chance someone else has hit the same issue.

Additional Resources

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Share via
Copy link
Powered by Social Snap