DEV Community

Pavel Sanikovich
Pavel Sanikovich

Posted on

Go From Zero to Depth — Part 8: Context Isn’t Cancellation — It’s a Protocol

Most beginners meet context.Context as a strange requirement. Functions suddenly demand an extra argument. You pass context.Background() everywhere, add WithCancel or WithTimeout when a tutorial tells you to, and move on. The code compiles, tests pass, and the meaning of context remains vague.

This is unfortunate, because context is one of the clearest expressions of Go’s philosophy. It is not a helper. It is not just cancellation. It is a protocol for coordinating lifetimes across boundaries you don’t control.

To understand context, you need to forget the idea that it is about stopping things. Cancellation is only a symptom. The real purpose of context is scope.

Let’s start with the simplest example:

ctx := context.Background()
Enter fullscreen mode Exit fullscreen mode

This does nothing. It has no deadline, no cancellation signal, no values. And yet it is not useless. It establishes a root. Every real context grows from a root. Without that root, lifetimes become unstructured.

Now consider a server handler:

func handle(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    process(ctx)
}
Enter fullscreen mode Exit fullscreen mode

That single line carries a huge amount of information. It tells you that process must respect the lifetime of the HTTP request. If the client disconnects, times out, or cancels the request, process must stop. This is not optional. This is part of the contract.

That contract is what makes context a protocol.

Look at a typical mistake:

func process(ctx context.Context) {
    go work()
}
Enter fullscreen mode Exit fullscreen mode

The goroutine ignores the context entirely. It outlives the request. It may continue working long after the client is gone. This is not a bug you can catch with tests. It is a design error.

The correct version looks like this:

func process(ctx context.Context) {
    go work(ctx)
}
Enter fullscreen mode Exit fullscreen mode

And inside:

func work(ctx context.Context) {
    select {
    case <-ctx.Done():
        return
    default:
        doWork()
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the lifetime is explicit. The goroutine exists because the context exists. When the context ends, so does the work. This is not cancellation as an afterthought; it is structured concurrency.

This is why context.Background() in deep layers is often a smell. It breaks the chain. It creates work with no owner.

Contexts are meant to be passed, not created arbitrarily. A function should only create a new context when it is defining a new lifetime boundary.

For example:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Enter fullscreen mode Exit fullscreen mode

This creates a sub-scope. It says: “This operation must finish within one second, regardless of what the parent allows.” That is a design decision, not a convenience.

Another common misunderstanding is using context to pass data.

ctx = context.WithValue(ctx, "userID", id)
Enter fullscreen mode Exit fullscreen mode

This works, but it should make you uncomfortable. Context values are for request-scoped metadata that crosses API boundaries, not for normal parameters. If a function requires a value to operate correctly, it should take it explicitly. Context is for data that must flow everywhere but should not define behavior.

Good examples include request IDs, authentication tokens, trace IDs. Bad examples include business logic flags, configuration, or domain objects.

This distinction matters because context values are invisible. They bypass the type system. Overusing them turns APIs into riddles.

Another important aspect of context is that it is read-only. You cannot cancel a context you didn’t create. This enforces ownership. If a function receives a context, it can observe cancellation but cannot control it. Only the creator owns the right to end the lifetime.

This mirrors everything you’ve learned so far. Ownership. Lifetimes. Boundaries.

Contexts also compose naturally. A deadline implies cancellation. Cancellation propagates downward. Values flow with the scope. All of this happens without explicit wiring between layers. The protocol is implicit, but the behavior is consistent.

Consider this:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

go taskA(ctx)
go taskB(ctx)
Enter fullscreen mode Exit fullscreen mode

Both tasks share the same fate. When the context expires, both must stop. You didn’t need a channel. You didn’t need shared variables. The protocol handled it.

This is why context works so well with goroutines and channels. It doesn’t replace them. It coordinates them.

Beginners often treat context as boilerplate. Experienced Go developers treat it as a map of responsibility. If you follow the protocol, your programs shut down cleanly, release resources predictably, and behave well under failure. If you ignore it, you get goroutines that never die and bugs that appear only under load.

The key idea is simple: every goroutine should have a reason to exist, and that reason should be tied to a context. When the reason disappears, so should the goroutine.

Once you internalize this, you stop scattering context.Background() and start thinking in terms of lifetimes. That shift is subtle, but it’s where Go codebases become robust.

In the next part, we’ll move away from concurrency primitives and look at something that surprises many newcomers: error handling. We’ll explain why Go treats errors as values, why this design fits everything you’ve learned so far, and why fighting it leads to worse code.

By now, the pattern should be unmistakable. Go is not minimal because it lacks features. It is minimal because it gives you just enough structure to make responsibility visible.


Want to go further?

This series focuses on understanding Go, not just using it.

If you want to continue in the same mindset, Educative is a great next step.

It’s a single subscription that gives you access to hundreds of in-depth, text-based courses — from Go internals and concurrency to system design and distributed systems. No videos, no per-course purchases, just structured learning you can move through at your own pace.

👉 Explore the full Educative library here

Top comments (0)