Go-context-timeouts voorkomen dat trage database-aanroepen en externe verzoeken zich opstapelen. Leer over het doorgeven van deadlines, annulering en veilige standaardinstellingen.

Een enkel traag verzoek is zelden "gewoon traag." Terwijl het wacht, houdt het een goroutine levend, reserveert geheugen voor buffers en response-objecten en bezet het vaak een databaseverbinding of een plek in een pool. Als genoeg trage verzoeken zich opstapelen, stopt je API met nuttig werk omdat de beperkte resources vast blijven zitten.
Je merkt het meestal op drie plekken. Goroutines stapelen zich op en de scheduler-overhead stijgt, waardoor latency voor iedereen verslechtert. Databasepools raken uitgeput, waardoor zelfs snelle queries achter trage moeten wachten. Geheugen groeit door in-flight data en half opgebouwde responses, wat de GC-werktijd vergroot.
Meer servers toevoegen lost het probleem vaak niet op. Als iedere instantie tegen dezelfde bottleneck aanloopt (een kleine DB-pool, een trage upstream, gedeelde rate limits), verplaats je alleen de rij en betaal je meer terwijl errors nog steeds pieken.
Stel je een handler voor die uitsplitst: hij laadt een gebruiker uit PostgreSQL, roept een payments-service aan en daarna een recommendation-service. Als de recommendation-call vastloopt en niets het annuleert, eindigt het verzoek nooit. De DB-verbinding kan terugkeren, maar de goroutine en HTTP-client-resources blijven bezet. Vermenigvuldig dat met honderden verzoeken en je krijgt een langzame meltdown.
Het doel is simpel: stel een duidelijke tijdslimiet in, stop met werken als de tijd om is, maak resources vrij en geef een voorspelbare fout terug. Go-context-timeouts geven elke stap een deadline zodat werk stopt wanneer de gebruiker niet meer wacht.
Een context.Context is een klein object dat je door je aanroepketen geeft zodat elke laag het over één ding eens is: wanneer dit verzoek moet stoppen. Timeouts zijn de gebruikelijke manier om te voorkomen dat één trage afhankelijkheid je server vastzet.
Een context kan drie soorten informatie dragen: een deadline (wanneer werk moet stoppen), een annuleringssignaal (iemand besloot eerder te stoppen) en een paar request-gescopeerde waarden (gebruik dit spaarzaam en nooit voor grote data).
Annulering is geen magie. Een context biedt een Done()-kanaal. Als dat sluit, is het verzoek geannuleerd of is de tijd om. Code die respect heeft voor context controleert Done() (vaak met een select) en keert vroeg terug. Je kunt ook ctx.Err() controleren om te weten waarom het eindigde, meestal context.Canceled of context.DeadlineExceeded.
Gebruik context.WithTimeout voor "stop na X seconden." Gebruik context.WithDeadline wanneer je al een exacte uiterste tijd weet. Gebruik context.WithCancel wanneer een ouderconditie het werk vroegtijdig moet stoppen (client verbroken, gebruiker navigeerde weg, je hebt al het antwoord).
Als een context geannuleerd wordt, is het juiste gedrag saai maar belangrijk: stop met werken, stop met wachten op trage I/O en geef een duidelijke fout. Als een handler wacht op een databasequery en de context eindigt, keer dan snel terug en laat de database-aanroep aborteren als die context ondersteunt.
De veiligste plek om trage verzoeken te stoppen is de grens waar verkeer je service binnenkomt. Als een verzoek gaat timeouten, wil je dat het voorspelbaar en vroeg gebeurt, niet nadat het goroutines, DB-verbindingen en geheugen heeft opgeslokt.
Begin bij de edge (load balancer, API-gateway, reverse proxy) en zet een harde limiet voor hoe lang een verzoek maximaal mag leven. Dat beschermt je Go-service zelfs als een handler vergeet een timeout te zetten.
Binnen je Go-server, configureer HTTP-timeouts zodat de server niet eeuwig wacht op een trage client of een gestagneerde response. Stel minimaal timeouts in voor het lezen van headers, het lezen van de volledige request-body, het schrijven van de response en het idle houden van verbindingen.
Kies een standaard request-budget dat bij je product past. Voor veel APIs is 1 tot 3 seconden een redelijke startwaarde voor typische verzoeken, met een hogere limiet voor bekende trage operaties zoals exports. Het precieze getal is minder belangrijk dan consistentie, meten en een duidelijk uitzonderingsbeleid.
Streaming-responses vragen extra aandacht. Het is makkelijk om per ongeluk een oneindige stream te maken waarbij de server de verbinding openhoudt en altijd kleine stukjes schrijft, of eeuwig wacht voor de eerste byte. Bepaal op voorhand of een endpoint echt een stream is. Zo niet, handhaaf een maximale totale tijd en een maximale time-to-first-byte.
Zodra de boundary een duidelijke deadline heeft, wordt het veel eenvoudiger om die deadline door de hele request heen te propageren.
De eenvoudigste plek om te beginnen is de HTTP-handler. Daar komt één verzoek je systeem binnen, dus het is een logische plek om een harde limiet te zetten.
Maak een nieuwe context met een deadline en zorg dat je hem annuleert. Geef die context door aan alles wat kan blokkeren: databasewerk, HTTP-calls of trage berekeningen.
func (s *Server) GetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
userID := r.URL.Query().Get("id")
if userID == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
user, err := s.loadUser(ctx, userID)
if err != nil {
writeError(w, ctx, err)
return
}
writeJSON(w, http.StatusOK, user)
}
Een goede regel: als een functie op I/O kan wachten, zou ze een context.Context moeten accepteren. Houd handlers leesbaar door details naar kleine helperfuncties te verplaatsen, zoals loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Als de deadline gehaald is (of de client verbreekt), stop met werken en geef een gebruiksvriendelijke response. Een gebruikelijke mapping is context.DeadlineExceeded naar 504 Gateway Timeout, en context.Canceled naar "client is gone" (vaak zonder response body).
func writeError(w http.ResponseWriter, ctx context.Context, err error) {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if errors.Is(err, context.Canceled) {
// Client ging weg. Doe geen extra werk.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Dit patroon voorkomt opstapeling. Zodra de timer afloopt, krijgen alle context-aware functies in de keten hetzelfde stopsignaal en kunnen ze snel afhandelen.
Zodra je handler een context met deadline heeft, is de belangrijkste regel simpel: gebruik exact diezelfde ctx tot in de database-aanroep. Zo zorgen timeouts dat werk stopt in plaats van alleen je handler te laten stoppen met wachten.
Met database/sql heeft de voorkeur de context-aware methodes:
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
row := s.db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE id = $1",
r.URL.Query().Get("id"),
)
var id int64
var email string
if err := row.Scan(&id, &email); err != nil {
// handle below
}
}
Als het handler-budget 2 seconden is, geef de database slechts een deel daarvan. Laat ruimte over voor JSON-encoding, andere afhankelijkheden en foutafhandeling. Een eenvoudige vuistregel is Postgres 30% tot 60% van het totale budget te geven. Bij een handler-deadline van 2 seconden kan dat bijvoorbeeld 800ms tot 1.2s zijn.
Wanneer de context geannuleerd wordt, vraagt de driver Postgres de query te stoppen. Meestal keert de verbinding terug naar de pool en kan hergebruikt worden. Als annulering plaatsvindt tijdens een slechte netwerkperiode, kan de driver die verbinding weggooien en later een nieuwe openen. Hoe dan ook, je voorkomt dat een goroutine eeuwig wacht.
Behandel timeouts anders dan echte DB-fouten bij het controleren van errors. Als errors.Is(err, context.DeadlineExceeded), dan was de tijd op en moet je een timeout teruggeven. Als errors.Is(err, context.Canceled), is de client weg en stop je stilletjes. Andere errors zijn normale queryproblemen (foute SQL, ontbrekend record, permissies).
Als je handler een deadline heeft, moeten ook je uitgaande HTTP-calls die respecteren. Anders geeft de client op, maar blijft je server wachten op een trage upstream en houdt goroutines, sockets en geheugen vast.
Bouw uitgaande requests met de parent context zodat annulering automatisch meereist:
func fetchUser(ctx context.Context, url string) ([]byte, error) {
// Add a small per-call cap, but never exceed the parent deadline.
ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close() // always close, even on non-200
return io.ReadAll(resp.Body)
}
Die per-call timeout is een vangnet. De parent request-deadline is nog steeds de echte baas. Eén klok voor het hele verzoek, plus kleinere caps voor risicovolle stappen.
Stel ook timeouts in op transportniveau. Context annuleert de request, maar transport-timeouts beschermen je tegen trage handshakes en servers die nooit headers sturen.
Een veelgemaakte valkuil: response bodies moeten op elk pad gesloten worden. Als je vroeg terugkeert (statuscode-check, JSON-decode error, context-timeout), sluit alsnog de body. Lekken van bodies kan verbindingen in de pool langzaam opeten en zorgen voor "random" latency-pieken.
Een concreet scenario: je API roept een payment-provider aan. De client time-out na 2 seconden, maar de upstream hangt 30 seconden. Zonder request-cancel en transport-timeouts betaal je die 30 seconden wachttijd voor elk achtergelaten verzoek.
Een enkel verzoek raakt meestal meer dan één trage stap: handlerwerk, een databasequery en een of meer externe APIs. Als je elke stap royaal timeout, groeit de totale tijd stilletjes totdat gebruikers het merken en je server volloopt.
Budgettering is de eenvoudigste oplossing. Stel één parent-deadline voor het hele verzoek en geef elke dependency een kleiner deel. Child-deadlines moeten eerder zijn dan de parent zodat je snel faalt en nog tijd overhoudt om een nette fout terug te geven.
Vuistregels die in echte services werken:
Vermijd het stapelen van timeouts die elkaar tegenwerken. Als je handler-context 2s heeft en je HTTP-client 10s, is dat veilig maar verwarrend. Als het andersom is, kan de client vroegtijdig afkappen om ongerelateerde redenen.
Voor background-werk (audit logs, metrics, e-mails) hergebruik de request-context niet. Gebruik een aparte context met een korte timeout zodat client-annuleringen belangrijk cleanup-werk niet stoppen.
De meeste timeout-bugs zitten niet in de handler. Ze zitten één of twee lagen lager, waar de deadline stilletjes verloren gaat. Als je timeouts bij de edge zet maar ze in het midden negeert, kun je nog steeds goroutines, DB-queries of HTTP-calls hebben die doorlopen nadat de client weg is.
De patronen die het vaakst voorkomen zijn eenvoudig:
context.Background() (of TODO). Dat koppelt werk los van de client-cancel en handler-deadline.ctx.Done() te checken. Het verzoek is geannuleerd, maar je code blijft wachten.context.WithTimeout. Je krijgt veel timers en verwarrende deadlines.ctx te gebruiken bij blokkerende calls (DB-queries, uitgaande HTTP, message publishes). Een handler-timeout doet niks als de dependency die het negeert.Een klassiek falen: je voegt een 2 seconde timeout in de handler toe, maar je repository gebruikt context.Background() voor de databasequery. Onder load blijft die trage query doorlopen nadat de client al opgegeven heeft en groeit de stapel.
Repareer de basis: geef ctx als eerste argument door je callstack. Voeg in lang werk korte checks toe zoals select { case <-ctx.Done(): return ctx.Err() default: }. Map context.DeadlineExceeded naar een timeout-response (vaak 504) en context.Canceled naar een client-cancel-stijl respons (vaak 408 of 499, afhankelijk van je conventies).
Timeouts helpen alleen als je ze kunt zien gebeuren en kunt bevestigen dat het systeem netjes herstelt. Als iets traag is, moet het verzoek stoppen, resources moeten vrijkomen en de API responsief blijven.
Log voor elk verzoek dezelfde kleine set velden zodat je normale requests met timeouts kunt vergelijken. Neem de context-deadline op (als die bestaat) en wat het werk beëindigde.
Nuttige velden zijn de deadline (of "none"), totale verstreken tijd, reden van annulering (timeout vs client canceled), een kort operation-label ("db.query users", "http.call billing") en een request ID.
Een minimaal patroon ziet er zo uit:
start := time.Now()
deadline, hasDeadline := ctx.Deadline()
err := doWork(ctx)
log.Printf("op=%s hasDeadline=%t deadline=%s elapsed=%s err=%v",
"getUser", hasDeadline, deadline.Format(time.RFC3339Nano), time.Since(start), err)
Logs helpen bij het debuggen van één verzoek. Metrics tonen trends.
Houd een paar signalen bij die meestal vroeg pieken als timeouts verkeerd staan: aantal timeouts per route en dependency, in-flight requests (moet onder load afvlakken), DB-pool wachttijd en latency-percentielen (p95/p99) gesplitst op succes vs timeout.
Maak traagheid voorspelbaar. Voeg een debug-only delay toe aan één handler, vertraag een DB-query met een opzettelijke sleep of gebruik een testserver die vertraagt voor uitgaande calls. Verifieer dan twee dingen: je ziet de timeout-fout en het werk stopt snel na annulering.
Een kleine loadtest helpt ook. Draai 20–50 gelijktijdige requests gedurende 30–60 seconden met één geforceerde trage dependency. Het aantal goroutines en in-flight requests zou moeten stijgen en daarna stabiliseren. Als ze blijven groeien, negeert iets context-annulering.
Timeouts helpen alleen als ze overal toegepast worden waar een request kan wachten. Doe voor je deploy één ronde over de codebase en controleer of dezelfde regels in elke handler gelden.
context.DeadlineExceeded en context.Canceled.http.NewRequestWithContext (of req = req.WithContext(ctx)) en de client heeft transport-timeouts (dial, TLS, response header). Vermijd http.DefaultClient in productiepaden.Een korte "trage dependency"-drill vóór release is de moeite waard. Voeg een kunstmatige 2 seconde vertraging toe aan één SQL-query en bevestig drie dingen: de handler retourneert op tijd, de DB-call stopt echt (niet alleen de handler) en je logs tonen duidelijk dat het een DB-timeout was.
Stel je een endpoint voor zoals GET /v1/account/summary. Eén gebruikersactie triggert drie dingen: een PostgreSQL-query (account plus recente activiteit) en twee externe HTTP-calls (bijv. een billing-statuscheck en een profielverrijkingslookup).
Geef het hele verzoek een harde 2 seconde budget. Zonder budget kan één trage dependency goroutines, DB-verbindingen en geheugen bezet houden totdat je API overal timeouts krijgt.
Een eenvoudige verdeling kan zijn: 800ms voor de DB-query, 600ms voor externe call A en 600ms voor externe call B.
Zodra je de overall deadline kent, geef je die door. Elke dependency krijgt een eigen kleinere timeout, maar erft nog steeds annulering van de parent.
func AccountSummary(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dbCtx, dbCancel := context.WithTimeout(ctx, 800*time.Millisecond)
defer dbCancel()
aCtx, aCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer aCancel()
bCtx, bCancel := context.WithTimeout(ctx, 600*time.Millisecond)
defer bCancel()
// Use dbCtx for QueryContext, aCtx/bCtx for outbound HTTP requests.
}
Als externe call B vertraagt en 2.5 seconden nodig heeft, moet je handler stoppen met wachten bij 600ms, het lopende werk annuleren en een duidelijke timeout-respons teruggeven aan de client. De client ziet een snelle fout in plaats van een oneindige spinner.
Je logs moeten duidelijk maken wat het budget gebruikte: de DB was snel klaar, extern A slaagde, extern B raakte zijn limiet en gaf context deadline exceeded terug.
Als één echt endpoint goed werkt met timeouts en annulering, maak er dan een herhaalbaar patroon van. Pas het end-to-end toe: handler-deadline, DB-calls en uitgaande HTTP. Kopieer daarna dezelfde structuur naar het volgende endpoint.
Je werkt sneller als je de saaie delen centraliseert: een boundary-timeout helper, wrappers die garanderen dat ctx doorgegeven wordt aan DB en HTTP-calls, en één consistente foutmapping en logformat.
Als je dit patroon snel wilt prototypen, kan Koder.ai (koder.ai) Go-handlers en service-calls genereren vanuit een chatprompt, en kun je de broncode exporteren om je eigen timeout-helpers en budgetten toe te passen. Het doel is consistentie: trage calls stoppen vroeg, fouten zien er hetzelfde uit en debuggen hangt niet af van wie het endpoint schreef.
Een traag verzoek houdt beperkte resources vast terwijl het wacht: een goroutine, geheugen voor buffers en response-objecten, en vaak een database- of HTTP-verbinding. Als genoeg verzoeken tegelijk wachten, ontstaan er rijen, loopt de latency op voor al het verkeer en kan de service falen, ook al zou elk verzoek uiteindelijk voltooid worden.
Stel een duidelijke deadline bij de request-boundary (proxy/gateway en in de Go-server), maak in de handler een getimede context en geef die ctx door aan elk blokkerend call (database en uitgaande HTTP). Als de deadline bereikt is, stop je snel met werken, stuur je een consistente timeout-respons en annuleer je lopende werk dat annulering ondersteunt.
Gebruik context.WithTimeout(parent, d) wanneer je wilt “stop na deze duur” — het meest gebruikelijk in handlers. Gebruik context.WithDeadline(parent, t) als je al een vaste uiterste tijd hebt die je moet aanhouden. Gebruik context.WithCancel(parent) wanneer een interne voorwaarde vroegtijdig werk moet stoppen, bijvoorbeeld “we hebben al een antwoord” of “de client is weg”.
Roep altijd de cancel-functie aan, meestal direct met defer cancel() na het maken van de afgeleide context. Cancelen ruimt de timer op en geeft goede stop-signalen aan child-work, vooral in codepaden die vroeg terugkeren voordat de deadline afloopt.
Maak de request-context één keer in de handler en geef die als eerste argument door aan functies die kunnen blokkeren. Een handige controle is zoeken naar context.Background() of context.TODO() binnen request-paden; die breken vaak de cancel-propagatie doordat ze werk loskoppelen van de request-deadline.
Gebruik context-aware database-methodes zoals QueryContext, QueryRowContext en ExecContext (of de equivalenten van je driver). Als de context eindigt, kan de driver Postgres vragen de query te annuleren zodat je niet blijft verbranden aan tijd en verbindingen nadat het verzoek al voorbij is.
Koppel de parent request-context aan de uitgaande request met http.NewRequestWithContext(ctx, ...) en configureer ook client/transport timeouts zodat je beschermd bent tijdens connect, TLS en het wachten op response-headers. Sluit in alle paden altijd resp.Body om te voorkomen dat verbindingen in de pool lekken.
Bepaal eerst een totaalbudget voor het verzoek en geef vervolgens elke dependency een kleinere slice die daarin past, met een kleine buffer voor handler-overhead en response-encoding. Als de parent-context nog maar weinig tijd heeft, start dan geen duur werk dat onmogelijk vóór de deadline klaar kan zijn.
Een gebruikelijke keuze is context.DeadlineExceeded mappen naar 504 Gateway Timeout met een korte boodschap zoals “request timed out.” context.Canceled betekent meestal dat de client is weggegaan; vaak is het beste om te stoppen en niets te schrijven, zodat je geen extra resources verspilt.
De meest voorkomende fouten zijn het weggooien van de request-context door context.Background() te gebruiken, retries of sleeps zonder ctx.Done() te checken, en het vergeten ctx mee te geven aan blokkerende calls. Een subtiele fout is te veel onafhankelijke timeouts inbouwen, waardoor fouten lastig te begrijpen zijn en onverwacht vroeg afkappen kan optreden.