EthicsComputerCourses
Join training +91 90532 10052

context: Cancellation & Deadlines

Go · Lesson 26 of 43 Advanced 6 min read Updated

Imagine a user in Pune opens a train search page, your server starts three database queries and two API calls, and then the user closes the tab. All that work is now wasted. Or a payment API hangs, and every request waiting on it piles up until the server runs out of memory. Go's context package solves both problems: it carries a cancellation signal and a deadline through every function involved in one piece of work.

In this lesson you will create contexts with timeouts and cancel functions, make your functions respect them, see how cancellation flows from parent to child, record a reason with a cause, and carry request-scoped values.

The Context type

A context.Context is an interface with four methods. The two you use most are:

  • Done() returns a channel that is closed when the work should stop.
  • Err() returns nil while the context is active, then context.Canceled or context.DeadlineExceeded explaining why it stopped.

Every context tree starts from context.Background(), an empty context that is never cancelled. You derive new contexts from it with functions such as WithTimeout and WithCancel. By convention, a function that may block or do I/O takes a context as its first parameter, named ctx.

Timeouts with WithTimeout

context.WithTimeout(parent, d) returns a new context that is cancelled automatically after d, plus a cancel function. Your slow function selects on ctx.Done() alongside its real work:

Go
package main

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

func fetchFare(ctx context.Context, route string) (int, error) {
	select {
	case <-time.After(200 * time.Millisecond): // a slow fare API
		return 1450, nil
	case <-ctx.Done():
		return 0, fmt.Errorf("fare for %s: %w", route, ctx.Err())
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
	defer cancel()

	fare, err := fetchFare(ctx, "Delhi-Jaipur")
	if err != nil {
		fmt.Println("error:", err)
		fmt.Println("timed out:", errors.Is(err, context.DeadlineExceeded))
		return
	}
	fmt.Println("fare:", fare)
}
Output
error: fare for Delhi-Jaipur: context deadline exceeded
timed out: true

The API needs 200 ms but the context allows 100 ms, so ctx.Done() closes first and the function returns at once. Change the timeout to 500 ms and the program prints fare: 1450.

Always call cancel, usually with defer cancel() on the next line, even when the timeout will fire on its own. It frees the timer and any resources tied to the context straight away; go vet warns if you forget.

Cancelling work yourself

context.WithCancel(parent) gives you a context that stops when you call cancel(). Here a token generator runs until the caller has enough:

Go
package main

import (
	"context"
	"fmt"
)

func tokens(ctx context.Context, out chan<- int) {
	defer close(out)
	for n := 1; ; n++ {
		select {
		case out <- n:
		case <-ctx.Done():
			return
		}
	}
}

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

	ch := make(chan int)
	go tokens(ctx, ch)

	for t := range ch {
		fmt.Println("token", t)
		if t == 3 {
			cancel()
			break
		}
	}
	fmt.Println("done")
}
Output
token 1
token 2
token 3
done

Without the context, the generator goroutine would block forever on its next send after main stops reading: a goroutine leak. With it, cancel() closes ctx.Done(), the select picks that case, and the goroutine exits cleanly.

Cancellation flows down the tree

Contexts form a tree. Cancelling a parent cancels every context derived from it, but cancelling a child never affects the parent. A server can therefore cancel one request's context, and every database query and API call started for that request stops too.

Go
package main

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

func main() {
	parent, cancelParent := context.WithCancel(context.Background())
	child, cancelChild := context.WithTimeout(parent, time.Hour)
	defer cancelChild()

	cancelParent()
	<-child.Done()
	fmt.Println("child:", child.Err())

	ctx, cancel := context.WithCancelCause(context.Background())
	cancel(errors.New("user closed the tab"))
	fmt.Println(ctx.Err())
	fmt.Println(context.Cause(ctx))
}
Output
child: context canceled
context canceled
user closed the tab

The child had a one-hour timeout, yet it stopped immediately because its parent was cancelled. WithCancelCause (Go 1.20) lets you record why something was cancelled; ctx.Err() still says context canceled, while context.Cause(ctx) returns your reason, which makes logs far more useful. There are also WithTimeoutCause and WithDeadlineCause.

Request-scoped values

context.WithValue attaches a key and value to a context, for data that belongs to one request and must cross API boundaries, such as a request ID for logs or the logged-in user. Use your own unexported key type so packages cannot clash:

Go
package main

import (
	"context"
	"fmt"
)

type ctxKey string

const requestIDKey ctxKey = "request-id"

func logLine(ctx context.Context, msg string) {
	id, ok := ctx.Value(requestIDKey).(string)
	if !ok {
		id = "no-id"
	}
	fmt.Printf("[%s] %s\n", id, msg)
}

func main() {
	ctx := context.WithValue(context.Background(), requestIDKey, "req-7f3a")
	logLine(ctx, "payment started")
	logLine(context.Background(), "health check")
}
Output
[req-7f3a] payment started
[no-id] health check

Value returns any, so you use a type assertion to get the string back. Do not use context values for ordinary function inputs like a customer ID or page size; pass those as normal parameters, so the function's needs stay visible in its signature.

Where contexts come from in real programs

You rarely call context.Background() deep inside your code. Contexts usually arrive from outside:

  • In an HTTP handler, r.Context() is cancelled when the client disconnects or the server shuts down.
  • In tests, t.Context() (Go 1.24) is cancelled just before the test finishes.
  • In main, signal.NotifyContext(context.Background(), os.Interrupt) gives a context cancelled when the user presses Ctrl+C.

Pass that context down to every call that accepts one: http.NewRequestWithContext, database QueryContext methods, and your own functions. You will use r.Context() and signal.NotifyContext in net/http & a REST API.

Common mistakes

  • Forgetting defer cancel(). Resources stay held until the parent ends; go vet reports a lost cancel.
  • Ignoring ctx.Done() in long loops. A context only helps if the code checks it; select on ctx.Done() or check ctx.Err() regularly.
  • Starting from context.Background() inside a handler. That breaks the chain; use r.Context().
  • Using context values as a bag of parameters. Keep values for request-scoped data such as IDs and auth.

Key points

  • A context.Context carries a cancellation signal (Done), a reason (Err), an optional deadline and request-scoped values.
  • WithTimeout, WithDeadline and WithCancel derive new contexts; always call the returned cancel.
  • Functions that block should accept ctx first and stop when ctx.Done() closes.
  • Cancelling a parent cancels all its children; WithCancelCause and context.Cause record why.
  • Get contexts from r.Context(), t.Context() or signal.NotifyContext and pass them down.

Exercise

Write checkStock(ctx context.Context, item string, delay time.Duration) (bool, error) that pretends to call a warehouse API taking delay, and returns true unless the context ends first. In main, use one context with a 150 ms timeout for two calls in a row: "rice" taking 100 ms and then "dal" taking 100 ms. Print the result of each call and explain why the second fails.

Show solution
Go
package main

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

func checkStock(ctx context.Context, item string, delay time.Duration) (bool, error) {
	select {
	case <-time.After(delay):
		return true, nil
	case <-ctx.Done():
		return false, fmt.Errorf("check %s: %w", item, ctx.Err())
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
	defer cancel()

	for _, item := range []string{"rice", "dal"} {
		ok, err := checkStock(ctx, item, 100*time.Millisecond)
		if err != nil {
			fmt.Println("error:", err)
			continue
		}
		fmt.Println(item, "in stock:", ok)
	}
}
Output
rice in stock: true
error: check dal: context deadline exceeded

The timeout covers the whole operation, not each call. The first call uses 100 ms of the 150 ms budget, so the second call has only 50 ms left and the deadline fires before its 100 ms delay ends.

Quick Quiz

1. What does ctx.Done() return?

2. What error does ctx.Err() return after a timeout?

3. What happens to child contexts when the parent is cancelled?

4. Where should a context parameter go in a function signature?

Learn Go with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call