Skip to content
QSWEQB
mediumConceptCodingMidSeniorStaff

A client hangs up halfway through a request. How do you structure a Go HTTP service so it stops doing the work?

Every incoming request carries a context that the server cancels when the client disconnects or the handler returns. You thread it as the first parameter of every call, narrow it per dependency with context.WithTimeout, and back it with server-level timeouts and a graceful Shutdown.

7 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate name what cancels a request context, rather than treating cancellation as something the framework handles invisibly
  • That cancellation is described as a signal every blocking call must observe, not a mechanism that stops a goroutine
  • Whether the reason for ctx as the first parameter is given in terms of lifetime and a shared handler struct, not "it is the convention"
  • Whether context.Value is confined to request-scoped metadata, with an explicit reason why dependencies do not belong there
  • Does the candidate distinguish timeouts the handler sets from timeouts only the Server can set

Answer

Where the request context comes from

Every *http.Request handed to a handler already carries a context, reachable through r.Context(), and the server cancels it for you in three situations: the client's connection closes, an HTTP/2 client cancels the stream, or ServeHTTP returns. That third one is easy to forget and matters most, because it means any goroutine you start from a handler and hand the request context to is on a clock you did not set. The moment your handler returns, that context is dead.

So the plumbing job is narrow: you do not create the cancellation signal, you propagate it. If r.Context() reaches every call that can block — the database driver, the outbound HTTP client, the queue publish, your own select statements — then a client hanging up unwinds the whole tree of in-flight work. If it stops at the first function that forgot to take a context, everything below that point keeps burning a connection and a CPU for a response nobody will read.

Cancellation is a signal, not a stop button

Nothing in Go preempts a goroutine. Cancelling a context closes the channel returned by Done() and sets Err(), and that is the entire mechanism. Work stops only because something is watching. A tight for loop with no context check runs to completion after cancellation; a sql.Query called without a context runs to completion; a net.Conn read with no deadline and no context blocks until the kernel gives up. This is why "we use contexts" is a weaker answer than it sounds — what is being asked for is where the observation points are, and the honest answer names them: every I/O call takes the context, and every loop that runs longer than a few milliseconds checks ctx.Err() between iterations.

ctx first, never a struct field

A handler's dependencies — the pool, the client, the logger — live for the process lifetime and are shared by every concurrent request. A context lives for exactly one request. Storing it on the struct that holds your dependencies therefore puts a per-request value in process-scoped state, which is both a data race between concurrent requests and a lifetime error: whichever request wrote it last decides when everyone else's work gets cancelled.

The signature is also the documentation. Report(ctx, tenant) tells a caller that this call can be cancelled and may return context.Canceled; Report(tenant) promises the opposite, and a struct field hides the difference from the compiler and the reader alike.

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os/signal"
	"syscall"
	"time"
)

type ctxKey int

const requestIDKey ctxKey = iota // unexported type: no other package can collide with this key

// RequestID reads request-scoped metadata. Note the two-value assertion:
// a missing value is normal, not a panic.
func RequestID(ctx context.Context) string {
	id, _ := ctx.Value(requestIDKey).(string)
	return id
}

type Report struct{ Rows int }

// Store holds dependencies. It does NOT hold a context: one Store serves
// every request, and a context belongs to exactly one of them.
type Store struct {
	pool *http.Client
}

// ctx is the first parameter, and the deadline it carries is honoured by
// whatever this method calls next.
func (s *Store) Report(ctx context.Context, tenant string) (*Report, error) {
	// Per-call budget. Cancelled by this timeout OR by the caller's ctx,
	// whichever fires first. The defer is not optional: without it the
	// timer and the child context stay alive until the deadline passes.
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"http://reports.internal/"+tenant, nil)
	if err != nil {
		return nil, err
	}
	resp, err := s.pool.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	return &Report{Rows: 1}, nil
}

func (s *Store) handleReport(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // cancelled when the client goes away or ServeHTTP returns

	rep, err := s.Report(ctx, r.URL.Query().Get("tenant"))
	if err != nil {
		switch {
		case errors.Is(err, context.Canceled):
			// The client left. Nobody will read a response body, so don't
			// write one, and don't log this as a server-side failure.
			return
		case errors.Is(err, context.DeadlineExceeded):
			http.Error(w, "upstream timed out", http.StatusGatewayTimeout)
		default:
			http.Error(w, "internal error", http.StatusInternalServerError)
		}
		return
	}

	// Work that must outlive the request gets a context detached from
	// cancellation but keeping the values (context.WithoutCancel, Go 1.21+).
	go s.audit(context.WithoutCancel(ctx), rep)

	w.WriteHeader(http.StatusOK)
}

func (s *Store) audit(ctx context.Context, rep *Report) {
	_ = RequestID(ctx)
	_ = rep
}

context.WithTimeout composes downwards: the child is cancelled by its own deadline or by anything that cancels its parent, so a per-dependency timeout never lets a call outlive the request that started it. Deriving from context.Background() inside a handler breaks exactly that property, and is the most common way a service ends up with orphaned work. Since Go 1.20, context.Cause(ctx) tells you which cancellation in the chain fired, and Go 1.21's context.WithTimeoutCause lets you attach your own error to a deadline, which turns "context deadline exceeded" in a log line into something that names the dependency.

What context.Value is for

context.Value is for request-scoped metadata that crosses API boundaries without being part of the call's contract: a request or trace ID, the authenticated principal, a locale, the current span. Its defining property is that intermediate layers can carry it without knowing it exists.

Dependencies fail that test. A database handle is not scoped to a request, it is scoped to the process, and putting one in a context makes it invisible to the compiler — a missing key is a nil at runtime rather than a build failure, and a function's real requirements no longer appear in its signature. The mechanical rules follow: keys are an unexported type so no other package can collide with yours, values are read through a typed accessor rather than an inline assertion at each use site, and the values must be safe for concurrent reads because the whole tree is shared.

Timeouts your handler cannot set for itself

A per-call WithTimeout bounds work you initiate. It does nothing about a client that opens a connection and sends request headers one byte per second, because your handler has not been invoked yet. That is what ReadHeaderTimeout is for, and it is the field a bare http.ListenAndServe leaves at zero, meaning no limit at all. Static analysers such as gosec flag a Server literal without it precisely because the zero value is a slowloris invitation.

The set worth knowing, in the order they apply: ReadHeaderTimeout for the headers, ReadTimeout for reading the entire request including the body, WriteTimeout from the end of the header read to the end of the response write, and IdleTimeout for how long a keep-alive connection may sit between requests. The middle two are blunt, since they cap legitimate large uploads and slow streaming responses too; http.NewResponseController (Go 1.20) exists to relax them for a single request rather than forcing you to disable them globally. http.TimeoutHandler sits at a different level again: it cancels the request context and writes a 503 after its limit, but the handler goroutine carries on, and its later writes just return http.ErrHandlerTimeout.

Shutting down without dropping requests

Shutdown closes the listeners, closes idle connections, then waits for in-flight requests to finish. The context you pass bounds how long you are willing to wait, not how long the handlers may run — if the deadline passes, Shutdown returns the context's error and those handlers are still going. It also does not touch hijacked connections, so WebSocket and other upgraded connections need their own shutdown path. Meanwhile ListenAndServe returns http.ErrServerClosed immediately, which is why it belongs in a goroutine whose error you check against that sentinel rather than logging as a crash.

// Same file, continued.
func main() {
	store := &Store{pool: &http.Client{}}

	mux := http.NewServeMux()
	mux.HandleFunc("/report", store.handleReport)

	srv := &http.Server{
		Addr:    ":8080",
		Handler: mux,
		// The one nobody sets. Without it a client can dribble out request
		// headers indefinitely and hold a connection open for free.
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(),
		syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()

	<-ctx.Done() // SIGTERM from the orchestrator

	// Bound the wait, not the handlers.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("shutdown: requests still running at deadline: %v", err)
	}
}

Note what Shutdown deliberately does not do: it never cancels the contexts of requests already in flight, which is the right default, since those clients are waiting for real answers. If you want to escalate — drain politely for fifteen seconds, then tell handlers to give up — you build that yourself, typically by giving the server a BaseContext you control and cancelling it once the grace period expires. Claiming that Shutdown cancels request contexts is the detail that separates having run this in production from having read about it, because it decides whether your rolling deploys lose requests.

Cancellation only works where something is watching for it. The context is the easy part; the discipline is that every blocking call in the service takes one, and every long loop checks it.

Likely follow-ups

  • Your handler returns as soon as the context is cancelled, but the database query it started keeps running. Whose problem is that?
  • Shutdown returned its deadline error and requests are still in flight. What do you do next?
  • How would you enforce one total budget across three sequential upstream calls instead of a timeout per call?
  • When would you want work started by a request to survive that request being cancelled, and how do you get a context for it?

Related questions

Further reading

contextnet-httpcancellationtimeoutsgraceful-shutdown