Context Cancel Trees Internals

Updated

September 8, 2026

Context Cancel Trees Internals

Overview

context.Context is a tree of cancellation and values. Understanding parent→child propagation explains goroutine leaks, deadline bugs, and why context.Background() at random depths is an outage pattern.

Diagram: Cancel flows down

  parent ctx
     ├── child (timeout)
     └── child (values)
  cancel(parent) ──► cancels all children
  cancel(child)  ──► does NOT cancel parent

Tree Shape

Background
  └─ WithValue(request_id)
       └─ WithTimeout(2s)     <- parent cancel cancels children
            ├─ WithCancel     <- child cancel does NOT cancel parent
            └─ WithTimeout(500ms)

Rules:

  1. Cancel/timeout flows down.
  2. Child cancel does not cancel siblings/parent.
  3. Values flow by lookup up the parent chain.
  4. Always defer cancel() for WithCancel/Timeout/Deadline.

Implementation Intuition

Each cancelable context holds:

  • A done channel (closed on cancel)
  • Parent linkage / children set
  • Optional timer for deadlines
  • Optional cause error (WithCancelCause)

Closing parent walks children. This is why leaking cancel funcs can retain subtrees longer than you expect.

Values

type key struct{} // unexported key type avoids collisions
ctx = context.WithValue(ctx, key{}, rid)

Do not store optional params that belong in function arguments. Values are for request-scoped data (IDs, loggers), not control flow.

Error Taxonomy

ctx.Err() == context.Canceled
ctx.Err() == context.DeadlineExceeded
// WithCancelCause: context.Cause(ctx)

Map these to client retries carefully — cancel from client disconnect ≠ retryable server error necessarily.

Experiment

go mod init example
go run .
package main

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

func main() {
    parent, cancel := context.WithCancel(context.Background())
    defer cancel()
    child, cancelChild := context.WithTimeout(parent, 50*time.Millisecond)
    defer cancelChild()

    stop := make(chan struct{})
    go func() {
        <-child.Done()
        fmt.Println("child", child.Err())
        close(stop)
    }()

    <-stop
    cancel() // parent cancel after child already done
    fmt.Println("parent", parent.Err())

    // child cancel does not kill parent
    p2, c2 := context.WithCancel(context.Background())
    ch, c3 := context.WithCancel(p2)
    c3()
    fmt.Println("child2", ch.Err(), "parent2", p2.Err())
    c2()
}

What to notice: Timeout sets child error first; canceling a child leaves parent active until its own cancel.

Try next: Pass context.WithoutCancel(ctx) (Go 1.21+) when you must detach a background job intentionally — and document why.