Context
context.Context carries cancellation, deadlines and request-scoped values through every call. It replaces interruption, timeouts and ThreadLocal.
Section 18 of 31 8 min read
In Java, cancellation, timeouts and request-scoped data travel through several mechanisms: Thread.interrupt(), Future.cancel(), per-library timeout settings, and ThreadLocal/MDC for request data. Go has no thread-locals, because goroutines have no identity you can attach data to. Instead there is one explicit value, context.Context, passed as the first argument to every function that blocks, does I/O or calls another service.
What a context carries
A cancellation signal: ctx.Done() returns a channel that is closed when the work should stop, and ctx.Err() says why.
An optional deadline: a point in time after which the context cancels itself.
Request-scoped values: trace IDs, the authenticated principal. Narrow and deliberate, not a general-purpose bag.
Contexts form a tree. Deriving a child adds a deadline or a value; cancelling a parent cancels every child beneath it, but never the other way round.
Where contexts come from
// The root: never cancelled, no deadline, no values.
// Create it at the top of the call tree: main, tests, jobs.
ctx := context.Background()
// A placeholder while refactoring: "a real context should
// be passed in here, I haven't wired it up yet".
ctx = context.TODO()
// In an HTTP handler: cancelled when the client disconnects
// or when the handler returns.
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
...
}
// In a test (Go 1.24+): cancelled when the test finishes
func TestGet(t *testing.T) {
ctx := t.Context()
...
}Cancelling work
Future<Report> f = executor.submit(() -> build(id));
// Later: best-effort interrupt of the task's thread.
// Libraries that don't check interruption keep running.
f.cancel(true);ctx, cancel := context.WithCancel(parent)
defer cancel() // always: releases resources on every path
go func() {
report, err := build(ctx, id) // build passes ctx on down
...
}()
// Later: every goroutine and call using ctx sees Done() close
cancel()Each derived context has a parent
The timeout fires
Done() for it and everything derived from it, while its parent and siblings carry on. (WithoutCancel, below, is the deliberate exception.)Timeouts and deadlines
func (s *Store) Find(ctx context.Context, id string) (*Message, error) {
// Give this query at most 2s, and less if the caller's
// own deadline is sooner: a child never outlives its parent.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
row := s.db.QueryRowContext(ctx, "SELECT body FROM messages WHERE id = $1", id)
var m Message
if err := row.Scan(&m.Body); err != nil {
// context.DeadlineExceeded if our 2s elapsed,
// context.Canceled if the caller gave up first
return nil, fmt.Errorf("find message %s: %w", id, err)
}
return &m, nil
}
// WithDeadline takes an absolute time instead:
// ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))CompletableFuture<Result> f = fetchData(id)
.orTimeout(5, TimeUnit.SECONDS);
// The future fails after 5s, but the JDBC query and HTTP
// call inside fetchData keep running: each library needs
// its own timeout configured separately.ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
msg, err := svc.Find(ctx, id)
// When the 5s budget runs out:
// 1. the database driver aborts the query
// 2. outgoing HTTP calls using ctx are cancelled
// 3. Find returns an error wrapping context.DeadlineExceeded
// 4. the handler can respond 504 Gateway TimeoutChecking for cancellation in your own loops
func processBatch(ctx context.Context, items []Item) error {
for _, item := range items {
// Cheap, non-blocking check between units of work
if err := ctx.Err(); err != nil {
return err
}
if err := processItem(ctx, item); err != nil {
return err
}
}
return nil
}Work that must outlive the request
Sometimes a handler starts work that should finish even after the response is sent or the client disconnects, such as writing an audit record. r.Context() is cancelled as soon as the handler returns, so that work must detach from it. context.WithoutCancel (Go 1.21+) returns a context that keeps the parent’s values (trace IDs and so on) but is never cancelled and has no deadline. Give that work its own timeout so it cannot run forever.
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
order, err := h.orders.Create(r.Context(), parseOrder(r))
if err != nil {
http.Error(w, "could not create order", http.StatusInternalServerError)
return
}
// Survives the handler returning; still carries trace IDs.
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
go func() {
defer cancel()
if err := h.audit.Record(auditCtx, order); err != nil {
slog.ErrorContext(auditCtx, "audit failed", "order", order.ID, "err", err)
}
}()
w.WriteHeader(http.StatusCreated)
}Request-scoped values
// Tied to the current thread
MDC.put("requestId", requestId);
try {
chain.doFilter(req, res);
} finally {
MDC.remove("requestId"); // or it leaks to the next request
}
String id = MDC.get("requestId"); // anywhere on this thread// An unexported key type: no other package can collide with it
type ctxKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, ctxKey{}, id)
}
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(ctxKey{}).(string)
return id, ok
}
// Middleware: ctx := WithRequestID(r.Context(), newID())
// next.ServeHTTP(w, r.WithContext(ctx))Conventions
// Correct: ctx first, named ctx
func (s *Service) FindByID(ctx context.Context, id string) (*Message, error)
// Wrong: a context stored in a struct outlives the call it was for
type Service struct {
ctx context.Context // don't
}
// Also wrong: passing nil. Use context.TODO() if you have nothing better.Context vs ThreadLocal
ThreadLocal works in Spring MVC because one thread handles a request from start to finish. It breaks as soon as work hops threads: async executors, reactive pipelines, and, with virtual threads, it becomes costly at scale. Java 25’s ScopedValue is the JDK’s answer. Go chose explicit passing from the start: the data travels with the logical operation, visible in every signature, and the same ctx carries cancellation.