Jede Operation, die das eigene Programm verlässt — ein HTTP-Request, eine DB-Query, ein RPC-Call, sogar ein DNS-Lookup — kann beliebig lange dauern. Im glücklichen Fall sind es Millisekunden, im Katastrophenfall hängt eine Goroutine auf einer TCP-Verbindung, deren Gegenseite nicht antwortet, bis das OS die Verbindung nach Minuten oder Stunden schließt. Ein Server ohne Timeouts ist deshalb kein Server, sondern ein Wartezimmer mit unbegrenzter Verweildauer: irgendwann sind alle Goroutines, Connections und File-Descriptors aufgebraucht.
context.WithDeadline und context.WithTimeout sind Gos Antwort auf dieses Problem. Sie verwandeln einen Context in eine selbstauflösende Cancellation-Quelle: nach Ablauf der Zeit wird ctx.Done() automatisch geschlossen, ctx.Err() liefert context.DeadlineExceeded, und jede Funktion entlang der Aufrufkette, die diesen Context respektiert, hat die Chance, sauber abzubrechen. Der entscheidende Punkt ist nicht das Setzen des Timeouts — das ist eine Zeile —, sondern das konsequente Weiterreichen und das richtige Verhalten an den Schnittstellen: HTTP-Clients schließen TCP-Verbindungen, SQL-Treiber schicken cancel an die DB, und über Service-Grenzen hinweg propagiert die verbleibende Deadline als Header weiter.
WithDeadline und WithTimeout
Das Paket context bietet zwei nahezu gleichwertige Konstruktoren für zeitbasierte Cancellation. Der Unterschied liegt nicht in der Wirkung, sondern in der Art, wie der Endzeitpunkt ausgedrückt wird — absolut oder relativ.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)WithDeadline erwartet einen absoluten Zeitpunkt — eine time.Time. Das ist die richtige Wahl, wenn der Endzeitpunkt bereits feststeht: ein Cron-Job, der spätestens um 03:00 Uhr fertig sein muss, ein Batch-Import mit hartem SLA, oder die Weiterleitung einer von außen empfangenen Deadline. WithTimeout ist demgegenüber Convenience für den häufigsten Fall — eine Dauer ab jetzt — und intern exakt äquivalent zu WithDeadline(parent, time.Now().Add(timeout)). Pro Request, pro Operation, pro Versuch greift fast immer WithTimeout.
// Relativ: 5 Sekunden ab jetzt
ctxA, cancelA := context.WithTimeout(parent, 5*time.Second)
defer cancelA()
// Absolut: spätestens um Mitternacht
midnight := time.Date(2026, 5, 22, 0, 0, 0, 0, time.Local)
ctxB, cancelB := context.WithDeadline(parent, midnight)
defer cancelB()Beide Funktionen liefern dasselbe Tupel zurück: einen abgeleiteten Context und eine CancelFunc. Diese cancel-Funktion ist nicht optional, auch wenn der Timer ohnehin irgendwann abläuft — wir kommen gleich darauf zurück, warum defer cancel() trotzdem Pflicht ist.
Mechanik: Done, Err und der unsichtbare Timer
Hinter WithTimeout steckt ein time.AfterFunc-Timer. Sobald die Deadline erreicht ist — oder die Parent-Context vorher abbricht, oder cancel() aufgerufen wird, je nachdem was zuerst eintritt —, schließt der Context seinen Done()-Channel. Jede Goroutine, die in einem select auf <-ctx.Done() wartet, wird sofort aufgeweckt. Ab diesem Moment liefert ctx.Err() einen Wert ungleich nil, und zwar einen sehr spezifischen.
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
select {
case <-time.After(1 * time.Second):
fmt.Println("Arbeit fertig")
case <-ctx.Done():
fmt.Println("abgebrochen:", ctx.Err())
}abgebrochen: context deadline exceededDer Rückgabewert von ctx.Err() ist hier context.DeadlineExceeded — eine eigene Sentinel-Variable, die sich semantisch von context.Canceled unterscheidet. Letzteres bedeutet „jemand hat aktiv cancel() gerufen" (z. B. weil der Nutzer den Request abgebrochen hat), ersteres heißt „die Zeit ist abgelaufen". In Logs, Metriken und Retry-Logik ist dieser Unterschied wichtig: ein abgelaufenes Timeout deutet auf einen langsamen Downstream-Service hin, eine aktive Cancellation auf einen weggegangenen Client.
Und nun die Frage, die jeder Anfänger stellt: warum defer cancel(), wenn der Timer doch automatisch abläuft? Weil der Timer eine Resource ist. time.AfterFunc registriert sich in der Go-Runtime und bleibt bis zur Deadline aktiv — auch dann, wenn die zugehörige Funktion längst zurückgekehrt ist und niemand mehr auf den Context wartet. Bei einer 30-Sekunden-Deadline, die in 5 ms erfolgreich abgearbeitet wurde, hängt der Timer-Eintrag für 29.995 ms in der Runtime herum. Bei einem Server, der pro Sekunde tausend Requests verarbeitet, sind das tausende toter Timer gleichzeitig — der go vet-Check warnt nicht umsonst.
func fetch(parent context.Context, url string) error {
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel() // gibt den Timer sofort frei, sobald fetch zurückkehrt
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
_, err := http.DefaultClient.Do(req)
return err
}cancel() ist idempotent — mehrfach aufrufen ist gefahrlos —, und der defer garantiert, dass die Timer-Resource exakt beim Funktionsaustritt freigegeben wird, egal ob über Error-Return, Panic oder regulären Pfad.
Deadline-Vererbung: Kinder können nur verkürzen
Die wichtigste, aber selten ausgesprochene Regel der Context-Hierarchie: ein abgeleiteter Context kann eine Deadline nur verkürzen, nie verlängern. Wenn der Parent-Context bereits eine Deadline von 10 Sekunden hat und ein Kind-Context mit WithTimeout(parent, 1*time.Hour) erzeugt wird, dann ist die effektive Deadline des Kindes nicht eine Stunde — sondern 10 Sekunden. Der frühere der beiden Zeitpunkte gewinnt immer.
parent, cancelP := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelP()
// Versuch, das Kind länger laufen zu lassen — bringt nichts
child, cancelC := context.WithTimeout(parent, 1*time.Hour)
defer cancelC()
deadline, _ := child.Deadline()
fmt.Println(time.Until(deadline)) // ≈ 10s, nicht 1hDas ist keine Schwäche der API, sondern ihr Kern. Eine Aufrufkette ist hierarchisch: wenn der oberste Aufrufer entschieden hat, dass die gesamte Operation nach 10 Sekunden vorbei sein muss, dann darf keine Unter-Operation diese Vorgabe brechen — der HTTP-Handler weiß besser als die DB-Funktion, wie viel Zeit insgesamt zur Verfügung steht. Funktionen tiefer in der Kette dürfen die Deadline lokal weiter einschränken (z. B. „diese eine Query darf maximal 2 Sekunden dauern, selbst wenn der Request 10 hätte"), aber niemals lockern.
Wer eine Operation tatsächlich von der Parent-Deadline lösen will — selten und mit gutem Grund — muss context.Background() als neuen Wurzel-Parent verwenden, nicht den vererbten Context. Das ist der berüchtigte „Context-Detach": eine Hintergrundaufgabe, die nach Abschluss des Requests weiterlaufen soll. Aber Vorsicht, das hebelt die ganze Cancellation-Kette aus und ist fast immer ein Code Smell.
Deadline-Propagation in verteilten Systemen
In Microservice-Architekturen ist die Deadline das wichtigste Stück Metadaten, das ein Request mit sich trägt — wichtiger als Tracing-IDs, fast so wichtig wie die Authentifizierung. Service A hat 5 Sekunden, um seinem Client zu antworten. Er ruft Service B auf. Wenn B selbst 10 Sekunden für seine Arbeit braucht, ist das aus Sicht von A irrelevant: A hat schon nach 5 Sekunden aufgegeben, und jede Arbeit, die B nach Sekunde 5 leistet, ist verschwendete CPU für ein Ergebnis, das niemand mehr sehen will.
Die saubere Lösung ist Deadline-Propagation: Service A serialisiert die verbleibende Deadline (z. B. als Header grpc-timeout oder X-Request-Deadline), Service B deserialisiert sie und baut sich daraus seinen eigenen Context mit WithDeadline. Damit hat B exakt die gleiche Zeitvorgabe wie A — abzüglich Netzwerklatenz —, und kann seinerseits weiter propagieren, falls er C aufruft.
// Service A: ausgehend
func callServiceB(ctx context.Context, req *http.Request) (*Response, error) {
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
req.Header.Set("X-Request-Deadline", fmt.Sprintf("%dms", remaining.Milliseconds()))
}
return doRequest(ctx, req)
}
// Service B: eingehend
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h := r.Header.Get("X-Request-Deadline"); h != "" {
if ms, err := strconv.Atoi(strings.TrimSuffix(h, "ms")); err == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(ms)*time.Millisecond)
defer cancel()
}
}
// ... ctx ab hier durch alle Aufrufe reichen
}gRPC macht das automatisch: context.WithTimeout auf Client-Seite wird als grpc-timeout-Header übertragen, und der Server-Stub baut beim Empfang automatisch einen Context mit der passenden Deadline. Wer reines HTTP nutzt, muss dieses Pattern selbst implementieren — meist als Middleware, die für jeden eingehenden Request den Header parst. Der Effekt ist messbar: Services, die Deadlines korrekt propagieren, brennen bei Überlast keine Ressourcen für tote Requests ab und vermeiden den klassischen Kaskadeneffekt, bei dem ein langsamer Downstream-Service durch aufgestaute Calls eine ganze Service-Mesh-Region in die Knie zwingt.
http.Client: Total-Timeout vs Context-Deadline
Der net/http-Client unterstützt zwei voneinander unabhängige Timeout-Mechanismen, und das Zusammenspiel ist eine der häufigsten Verwirrungen. Auf der einen Seite gibt es http.Client.Timeout — ein Total-Timeout, der vom Beginn von Do (inkl. DNS, Connect, TLS-Handshake) bis zum Lesen des letzten Response-Body-Bytes gilt. Auf der anderen Seite gibt es den Context, der über http.NewRequestWithContext an einen Request gebunden wird und denselben Lebenszyklus steuert.
var client = &http.Client{Timeout: 30 * time.Second} // globaler Fallback
func fetchUser(ctx context.Context, id string) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second) // request-spezifisch
defer cancel()
url := "https://api.example.com/users/" + id
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("user %s: timeout", id)
}
return nil, err
}
defer resp.Body.Close()
return parseUser(resp.Body)
}client.Timeout wirkt als Sicherheitsnetz für den Fall, dass jemand vergessen hat, einen Context-Timeout zu setzen — ein vernünftiger Default liegt bei 10 bis 30 Sekunden. Die feinkörnige Steuerung passiert über den Context: pro Request, pro Endpunkt, pro Retry-Versuch. Wenn beide gesetzt sind, gewinnt der kürzere — wieder die Min-Regel.
Wichtig ist, was der Client beim Cancel oder Deadline-Ablauf tatsächlich tut: er schließt die zugrundeliegende TCP-Verbindung. Das ist aggressiv, aber notwendig — eine halb-konsumierte Response kann nicht in den Connection-Pool zurückgegeben werden. In den Metriken sieht man bei aggressiven Timeouts deshalb erhöhte Connect-Latenz, weil der Pool öfter neu aufbauen muss. Das ist der Preis für korrekte Cancellation.
DB-Queries: Cancel-Signal an den Server
Im database/sql-Paket existiert seit Go 1.8 die *Context-Familie: QueryContext, ExecContext, QueryRowContext, PingContext, BeginTx. Jede davon nimmt einen Context als ersten Parameter, und das Verhalten bei Deadline-Ablauf ist auf dem Papier einheitlich — in der Praxis aber treiberabhängig.
func getOrder(ctx context.Context, db *sql.DB, id int64) (*Order, error) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
row := db.QueryRowContext(ctx, `SELECT id, customer, total FROM orders WHERE id = $1`, id)
var o Order
if err := row.Scan(&o.ID, &o.Customer, &o.Total); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return nil, fmt.Errorf("order %d: query timeout", id)
}
return nil, err
}
return &o, nil
}Was unterhalb passiert, hängt vom Treiber ab. Sowohl lib/pq als auch pgx schicken bei Cancel ein CancelRequest an PostgreSQL — eine separate Verbindung mit pg_cancel_backend-Effekt, die den laufenden Query auf der DB-Seite abbricht. Das ist enorm wertvoll: ohne diesen Mechanismus würde Go zwar lokal aufgeben, aber die DB würde den Query bis zum Ende durchrechnen und ein Ergebnis liefern, das niemand mehr liest — Resource-Verschwendung auf dem teuersten Knoten der Architektur. MySQL-Treiber wie go-sql-driver/mysql implementieren das ebenfalls über KILL QUERY.
Aber: nicht jeder Treiber für jede Datenbank kann das. Bei exotischen oder selbstgebauten Treibern lohnt sich ein Blick in den Source — wenn Cancel nur lokal die Goroutine entlässt, ohne der DB Bescheid zu geben, verbrennt jeder Timeout serverseitig Rechenzeit. In dem Fall hilft nur ein zusätzlicher Statement-Timeout in der DB selbst (SET statement_timeout in Postgres, max_execution_time in MySQL).
Nicht jede Operation braucht einen Context
Bei aller Begeisterung für saubere Cancellation: nicht jede Funktion gehört mit einem Context-Parameter dekoriert. Ein Context zahlt sich nur dort aus, wo es etwas zu canceln gibt — also bei Operationen, die blockieren können. Konkret: I/O (Netz, Disk, DB), Channel-Operationen ohne default, Wartepunkte mit time.After, externe Prozesse.
Eine reine in-process-Operation profitiert dagegen nicht. Ein Map-Lookup, eine arithmetische Berechnung, das Anhängen an einen Slice, ein nicht-blockierender Channel-Send mit default-Case — all das läuft in Nanosekunden bis Mikrosekunden ab. Ein Context-Check wäre teurer als die Operation selbst und liefert keinen Mehrwert, weil es nichts zu unterbrechen gibt.
Die Faustregel: wenn eine Funktion potenziell auf etwas wartet, das außerhalb des eigenen Programms liegt — TCP, Disk, andere Goroutine —, dann gehört ein ctx context.Context als erster Parameter dazu. Wenn sie deterministisch in begrenzter Zeit zurückkehrt, ist der Context nur Lärm. Ein häufiger Anti-Pattern ist das Aufrufen von time.Sleep in einer Funktion, die einen Context erhält. time.Sleep ignoriert den Context komplett und schläft stur bis zum Ende — auch wenn die Deadline längst abgelaufen ist. Die context-aware Variante ist immer ein select.
// FALSCH: ignoriert Cancel
func waitWrong(ctx context.Context, d time.Duration) {
time.Sleep(d) // läuft bis zum Ende, egal was ctx sagt
}
// RICHTIG: respektiert Cancel
func waitRight(ctx context.Context, d time.Duration) error {
select {
case <-time.After(d):
return nil
case <-ctx.Done():
return ctx.Err()
}
}HTTP-Client mit Retry und gestaffelten Timeouts
Ein realistisches Beispiel: ein HTTP-Client, der externe API-Calls macht, mit globalem Gesamtbudget pro Operation (30 Sekunden) und einem Pro-Versuch-Timeout (5 Sekunden), das auf bis zu drei Retries angewendet wird. Die Kunst liegt darin, bei jedem Retry einen neuen Context mit der noch verbleibenden Gesamtzeit abzuleiten — und nicht naiv jeden Versuch volle 5 Sekunden zu geben, denn nach drei Versuchen wären das 15 Sekunden plus Backoff, und das Gesamtbudget wäre längst überschritten.
func fetchWithRetry(parent context.Context, url string) (*http.Response, error) {
overall, cancelOverall := context.WithTimeout(parent, 30*time.Second)
defer cancelOverall()
const maxAttempts = 3
const perAttempt = 5 * time.Second
const backoff = 500 * time.Millisecond
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
deadline, _ := overall.Deadline()
remaining := time.Until(deadline)
if remaining <= 0 {
return nil, fmt.Errorf("budget exhausted: %w", lastErr)
}
attemptTimeout := perAttempt
if remaining < perAttempt {
attemptTimeout = remaining
}
ctx, cancel := context.WithTimeout(overall, attemptTimeout)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
cancel() // sofort freigeben — nicht defer in Schleife!
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if resp != nil {
resp.Body.Close()
}
lastErr = err
if errors.Is(err, context.Canceled) {
return nil, err
}
select {
case <-time.After(backoff * time.Duration(attempt)):
case <-overall.Done():
return nil, overall.Err()
}
}
return nil, fmt.Errorf("after %d attempts: %w", maxAttempts, lastErr)
}Drei Details verdienen Aufmerksamkeit. Erstens das cancel() direkt nach Do statt defer cancel() — in einer Schleife würde defer die cancel-Aufrufe bis zum Funktionsende aufstauen, was bei vielen Iterationen zum Resource-Leak führt. Zweitens die Berechnung der Restzeit über time.Until(deadline): der letzte Versuch in einer Retry-Serie bekommt nicht mehr die vollen 5 Sekunden, sondern nur, was vom Gesamtbudget übrig ist — möglicherweise nur 800 ms. Drittens der Backoff im select: ein nacktes time.Sleep würde die Cancellation des Parent-Context ignorieren, der select-Block respektiert sie.
Handler-Context bis in die DB durchreichen
Das zweite Pattern ist der direkte Weg vom HTTP-Handler bis in die Datenbank, ohne unterwegs den Context zu verlieren. Wenn der Client den Browser-Tab schließt, bevor die Response zurückkommt, schließt Go den http.Request.Context() automatisch — und wenn dieser Context konsequent durchgereicht wird, bricht auch die laufende DB-Query auf dem Server sofort ab. Keine verschwendete CPU, kein Connection-Slot blockiert für ein Ergebnis, das nie gelesen wird.
type Server struct {
db *sql.DB
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
query := r.URL.Query().Get("q")
results, err := s.searchOrders(ctx, query)
if err != nil {
switch {
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "timeout", http.StatusGatewayTimeout)
return
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
json.NewEncoder(w).Encode(results)
}
func (s *Server) searchOrders(ctx context.Context, q string) ([]Order, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, customer, total FROM orders WHERE customer ILIKE $1 LIMIT 100`,
"%"+q+"%",
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.Customer, &o.Total); err != nil {
return nil, err
}
out = append(out, o)
}
return out, rows.Err()
}Im Erfolgsfall sieht man von dem ganzen Mechanismus nichts — Code wie immer. Im Fehlerfall zeigt das errors.Is-Pattern den Wert der Unterscheidung: context.Canceled heißt „der Client ist weg, sparen wir uns die Response", context.DeadlineExceeded heißt „wir waren zu langsam, dem Client einen 504 schicken". Beide kommen aus demselben Channel, müssen aber semantisch getrennt werden, sonst landen interne SLA-Verletzungen in der Client-Disconnect-Statistik.
Interessantes
defer cancel() trotz Deadline
Auch wenn der Timer automatisch abläuft — defer cancel() gibt die Timer-Resource sofort beim Funktionsaustritt frei. Bei kurzlaufenden Operationen mit großzügigem Timeout sammeln sich sonst tausende toter Timer in der Runtime an. go vet warnt aus genau diesem Grund.
WithDeadline für absolute Zeit, WithTimeout für Dauer
Cron-Jobs, Batch-Fenster und propagierte Deadlines aus anderen Services bekommen WithDeadline(parent, t). Pro-Request-Timeouts, Retry-Versuche und alles, was sich als „X Sekunden ab jetzt" denkt, bekommt WithTimeout(parent, d). Intern dasselbe, semantisch klarer.
Kind-Deadline kann nur kürzer werden
WithTimeout(ctx, 1*time.Hour) bei einem Parent mit 10-Sekunden-Deadline ergibt eine effektive Deadline von 10 Sekunden, nicht einer Stunde. Der frühere Zeitpunkt gewinnt immer — das ist die zentrale Regel der Context-Hierarchie und der Grund, warum Deadline-Propagation überhaupt funktioniert.
http.Client schließt TCP bei Cancel
client.Do mit context-gebundenem Request schließt die zugrundeliegende TCP-Verbindung sofort, sobald die Deadline abläuft oder cancel() gerufen wird. Saubere Cancellation kostet damit Connect-Latenz bei nachfolgenden Requests, weil der Pool öfter neu aufbauen muss — der Preis ist es wert.
DeadlineExceeded ist nicht Canceled
context.DeadlineExceeded bedeutet „Timeout abgelaufen", context.Canceled bedeutet „jemand hat aktiv cancel() gerufen" (typisch: Client weg). In Logs, Metriken und Retry-Logik immer mit errors.Is unterscheiden — beides aus einem Topf zu werfen versteckt entweder Performance-Probleme oder Client-Verhalten.
time.Sleep ignoriert Context
Ein nacktes time.Sleep(d) schläft stur bis zum Ende und kennt keinen Context. In jeder Funktion, die einen Context entgegennimmt, gehört stattdessen ein select mit time.After und ctx.Done() — sonst hängt die Goroutine über die Deadline hinaus weiter.
Retry: pro Versuch neuen ctx mit Restzeit
Bei Retries niemals jeden Versuch mit dem vollen Per-Attempt-Timeout starten — das sprengt das Gesamtbudget. Stattdessen pro Iteration time.Until(overall.Deadline()) lesen und den Versuchs-Timeout auf das Minimum aus Per-Attempt-Limit und Restzeit setzen.
Service-Boundary: Deadline propagieren
An jeder Service-Grenze die verbleibende Deadline als Header serialisieren (grpc-timeout, X-Request-Deadline) und beim Empfang in einen lokalen WithDeadline umsetzen. gRPC macht das automatisch, bei reinem HTTP gehört es in eine Middleware — ohne diesen Schritt brennen Downstream-Services Ressourcen für längst aufgegebene Requests.
Weiterführende Ressourcen
Externe Quellen
context.WithDeadline— Go Documentationcontext.WithTimeout— Go Documentationhttp.Request.WithContext— Go Documentationdatabase/sql.DB.QueryContext- gRPC Deadlines (gRPC Blog)