8. Cancellation, Timeouts & Context Propagation

Cooperative cancellation, the idiomatic cancellation handle in each language, timing out a blocking or async call, and propagating a single cancellation signal through a chain of calls.

Cancellation is cooperative in every language on this manual -- there is no language here that can forcibly kill a running thread/coroutine/goroutine safely, only ask it to stop and trust the running code to check. Go's context.Context is the most disciplined, pervasive idiom for this (it's threaded through almost every function signature that does I/O); Kotlin's structured concurrency makes it close to automatic; Java historically relied on interrupt() (easy to get wrong); Python and JS both have a real, if newer, first-class handle (Task.cancel()/CancelledError, AbortController/AbortSignal).

Language Verdict: Pros, Cons & Recommendation

Go

4/5
  • context.Context is a single, pervasive, well-understood convention across the entire ecosystem
  • WithTimeout/WithDeadline/WithCancel cover every common case with one consistent API
  • Entirely manual, explicit propagation -- forgetting to pass ctx down breaks it silently
  • A goroutine that never checks ctx.Done() simply never stops, no matter how correctly the caller cancelled

Java

3/5
  • interrupt()/Future.cancel() have been available since early Java and are well understood
  • StructuredTaskScope now gives automatic scope-level cancellation of children
  • Future.get(timeout) does NOT cancel the underlying task -- a classic, easy-to-miss leak
  • Manual, layer-by-layer propagation with no compiler help; a swallowed InterruptedException silently breaks it

Kotlin

5/5
  • Cancellation propagates automatically through every suspend call chain with zero extra code
  • withTimeout unifies 'stop waiting' and 'stop the work' into one call, unlike Java's Future.get
  • A tight, non-suspending loop still needs an explicit isActive/yield() check, easy to forget
  • CancellationException must never be silently swallowed -- a real, if well-documented, footgun

Python

4/5
  • wait_for automatically cancels the wrapped task on timeout, matching Kotlin's safer design
  • Automatic propagation through await chains, same structural benefit as Kotlin
  • CancelledError swallowed by an overly broad except Exception is a common, real bug
  • No propagation at all for a synchronous/threaded call chain -- this section's benefits are asyncio-specific

JavaScript

3/5
  • AbortController/AbortSignal is a single, standardized, increasingly universal primitive across the platform and Node
  • AbortSignal.timeout(ms) gives a clean one-line timeout construction
  • Fully manual propagation, like Go's ctx, but without Go's decades of ecosystem-wide convention behind it yet
  • An async API only respects the signal if it was specifically written to check it -- no automatic enforcement
Recommendation: Structured-concurrency languages (Kotlin, and Python's asyncio to a lesser extent) give you automatic propagation almost for free; Go and JS require disciplined manual threading of ctx/AbortSignal through every layer; Java's classic Future.cancel API requires you to remember it's a two-step process (stop waiting, then separately cancel) that StructuredTaskScope only recently fixed.

Concurrency Mechanics, Side by Side

Cooperative Cancellation: Who Actually Checks?

Must-know

None of these five languages can forcibly stop a running unit of work mid-instruction -- every mechanism below relies on the running code checking a flag or hitting a designated interruption point.

func worker(ctx context.Context) { for { select { case <-ctx.Done(): return // cooperate by checking ctx.Done() default: doChunkOfWork() } } }

There's no interrupt-a-thread mechanism at all in Go -- ctx.Done() returns a channel that's closed when the context is cancelled, and cooperating means checking it (via select, typically) at reasonable intervals. Exactly like Java/Kotlin, a goroutine that never checks ctx.Done() simply never stops, even though the caller believes it cancelled the work.

The Idiomatic Cancellation Handle

Must-know
ctx, cancel := context.WithCancel(context.Background()) defer cancel() // ALWAYS defer this, even if you also call it explicitly elsewhere go worker(ctx) cancel() // closes ctx.Done()'s channel -- worker observes it on its next check

context.Context (specifically the cancel function returned by WithCancel/WithTimeout/WithDeadline) is THE idiomatic cancellation handle in Go, conventionally passed as the first parameter to any function that might block or do I/O. Forgetting defer cancel() is a well-known resource leak -- the context and its internal goroutine/timer aren't cleaned up until cancel is called, even after the operation naturally completes.

Timeouts on a Blocking or Async Call

Must-know
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() result, err := doWork(ctx) // doWork must itself check ctx.Done() to actually stop // if it doesn't check ctx, cancellation still 'happens' but doWork keeps running

WithTimeout gives you a context that auto-cancels after the duration, but -- just like the cooperative-cancellation caveat everywhere in this section -- doWork actually stopping depends entirely on it checking ctx.Done(). A network call using the ctx-aware standard library (net/http with the request's context, database/sql, etc.) does stop; a tight CPU loop that ignores ctx does not, identical to Java's Future.get(timeout) trap in spirit.

Propagating Cancellation Through a Call Chain

Recommended
func middleLayer(ctx context.Context) error { return innerCall(ctx) // convention: pass ctx down explicitly, always as arg #1 }

Propagation in Go is explicit and manual, but it's a strong, consistent convention rather than something enforced by the compiler: ctx is threaded through every function signature in the call chain by hand, and every ctx-aware standard-library call (HTTP, database, etc.) automatically respects whatever cancellation/deadline is baked into the ctx it was handed. Forgetting to pass ctx down (using context.Background() instead partway down the chain) silently breaks propagation, similar in spirit to Java's swallowed-interrupt bug.

Further Reading