How to handle Context cancellation in Go

When writing Go applications, especially long-running services, it’s essential to have a way to control how and when things should stop. That’s where context.Context comes in.

What is context in Go?

A Context is Go’s standard way of carrying deadlines, cancellation signals and other request-scoped values across API boundaries and goroutines. It provides a lightweight and idiomatic way to manage the lifecycle of your operations.

In Go, any function that performs a blocking operation (like I/O, background processing, or waiting for a signal) should receive a context.Context as its first parameter. This ensures that the function respects cancellations and timeouts when they happen.

func doSomething(ctx context.Context) error {
    // Your logic here
}

Creating a context with cancellation

One of the most common types of contexts is the one created with context.WithCancel. It gives you both a context object and a cancel() function that you can call later to signal a shutdown.

Here’s how it works:

// Create a new context and its cancel function:
ctx, cancel := context.WithCancel(context.Background())

// You can call the cancel function when you want the context to be cancelled.
defer cancel() // Make sure to cancel to avoid leaks

The cancel() function notifies all goroutines that are listening on ctx.Done() to stop what they’re doing.

Let’s look at a real example.

Example: Cancel a running operation

In the example below, we launch a goroutine that simulates work. After 5 seconds, we cancel the context from the main goroutine.

package main

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

func main() {
	// Create the child context and its cancel function:
	ctx, cancel := context.WithCancel(context.Background())

	// Once this function finish, call the cancel function to avoid leaks.
	defer cancel()

	fmt.Println("About to start working...")

	go func(ctx context.Context) {
		for {
			select {
			case <-ctx.Done():
				fmt.Println("Work cancelled.")
				return

			default:
				// We're running a really long task here:

				fmt.Println("Working...")
				time.Sleep(1 * time.Second)
			}
		}
	}(ctx)

	// Wait 5 seconds.
	// This will allow the long task to keep working.
	time.Sleep(5 * time.Second)

	// Cancel the current context. This will trigger ctx.Done()
	cancel()

	fmt.Println("Context cancelled.")

	// Wait two more seconds so we can see the finish result:
	time.Sleep(1 * time.Second)

	fmt.Println("All done.")
}

If we now execute this file, we get the following output:

About to start working...
Working...
Working...
Working...
Working...
Working...
Context cancelled.
Work cancelled.
All done.

What if I already have a Context?

Let’s say you’re working in a function that already receives a context.Context, maybe from an incoming HTTP request or from a parent process that controls the app lifecycle.

What if you want to launch a sub-operation that you might want to cancel independently, without affecting the original context?

That’s exactly where context.WithCancel(parentCtx) shines. You can create a child context that inherits from the parent but can be cancelled without interrupting the rest of the application.

func doWork(ctx context.Context) {
    childCtx, cancel := context.WithCancel(ctx)
    defer cancel() // Always cancel when done

    go func() {
        for {
            select {
            case <-childCtx.Done():
                fmt.Println("Child context cancelled.")
                return

            default:
                fmt.Println("Working in child context...")
                time.Sleep(1 * time.Second)
            }
        }
    }()

    time.Sleep(3 * time.Second)

    cancel()
    
    time.Sleep(1 * time.Second)
}

And from main:

func main() {
    parentCtx := context.Background()
    doWork(parentCtx)
}

Now, when executing this code you’ll see the following output:

Working in child context...
Working in child context...
Working in child context...
Child context cancelled.

Here’s what’s happening:

  1. doWork receives a parentCtx and spawns a childCtx.
  2. When we call cancel(), only the child context is affected.
  3. Any other operations using the parentCtx continue unaffected.
  4. We can control for how long and how we want to deal with childCtx without affecting its parent context.

Why is this useful?

This pattern is extremely helpful when you have parallel subtasks and only want to stop one of them. It gives you precise control without needing to shut down the entire process.

For example:

  • Cancelling an upload task but letting other background jobs continue.
  • Timing out a database query while keeping the HTTP request alive.
  • Stopping one specific worker inside a larger pool.

It’s all about scoping cancellation to the exact logic that needs it: no more, no less.

When should you use context?

  • Handling timeouts in HTTP requests.
  • Managing background jobs or workers.
  • Controlling dependent operations (for example, cancel all downstream tasks).
  • Gracefully shutting down your app. Take a look at this post.

Any blocking function should be built to listen for cancellation and exit cleanly when requested.

Summary

  • context.Context allows you to pass cancellation signals and deadlines across goroutines.
  • Always put context.Context as the first parameter in your functions.
  • Use context.WithCancel when you want to manually control when an operation should stop.
  • Combine context with select to listen for both work and stop signals.
  • It’s the idiomatic way to build clean, safe, and stoppable code in Go.

By making your code cancelable and context-aware, you write apps that are easier to manage, safer to deploy and ready for production.

See you next time!

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top