Los timeouts de contexto en Go evitan que llamadas lentas a BD y servicios externos se acumulen. Aprende propagación de deadlines, cancelación y valores por defecto seguros.

Una única petición lenta rara vez está "simplemente lenta". Mientras espera, mantiene viva una goroutine, ocupa memoria para buffers y objetos de respuesta, y con frecuencia mantiene una conexión a la base de datos o un hueco en un pool. Cuando suficientes peticiones lentas se acumulan, tu API deja de hacer trabajo útil porque sus recursos limitados están esperando.
Normalmente lo notas en tres sitios. Las goroutines se acumulan y la sobrecarga de planificación sube, así que la latencia empeora para todos. Los pools de la base de datos se quedan sin conexiones libres, así que incluso consultas rápidas empiezan a encolarse detrás de las lentas. La memoria sube por datos en tránsito y respuestas parcialmente construidas, lo que aumenta el trabajo del GC.
Añadir más servidores a menudo no lo arregla. Si cada instancia choca contra el mismo cuello de botella (un pool pequeño de BD, un upstream lento, límites compartidos de tasa), solo mueves la cola y pagas más mientras los errores siguen subiendo.
Imagina un handler que hace fan-out: carga un usuario desde PostgreSQL, llama a un servicio de pagos y luego a un servicio de recomendaciones. Si la llamada de recomendaciones se queda colgada y nadie la cancela, la petición nunca termina. La conexión a la BD puede devolverse, pero la goroutine y los recursos del cliente HTTP siguen ocupados. Multiplica eso por cientos de peticiones y obtienes un derretimiento lento.
El objetivo es simple: establecer un límite de tiempo claro, parar el trabajo cuando se acabe, liberar recursos y devolver un error predecible. Los timeouts con context en Go dan a cada paso un deadline para que el trabajo se detenga cuando el usuario ya no esté esperando.
Un context.Context es un objeto pequeño que pasas por la cadena de llamadas para que cada capa comparta una cosa: cuándo debe parar esta petición. Los timeouts son la forma habitual de evitar que una dependencia lenta ate tu servidor.
Un contexto puede llevar tres tipos de información: un deadline (cuando debe pararse el trabajo), una señal de cancelación (alguien decidió parar antes) y algunos valores con alcance de la petición (úsalos con moderación y nunca para datos grandes).
La cancelación no es magia. Un contexto expone un canal Done(). Cuando se cierra, la petición se ha cancelado o se acabó el tiempo. El código que respeta el contexto comprueba Done() (a menudo con un select) y vuelve temprano. También puedes comprobar ctx.Err() para saber por qué terminó, normalmente context.Canceled o context.DeadlineExceeded.
Usa context.WithTimeout para "parar tras X segundos". Usa context.WithDeadline cuando ya conoces la hora exacta de corte. Usa context.WithCancel cuando una condición padre deba detener el trabajo (cliente desconectado, usuario navegó fuera, ya tienes la respuesta).
Cuando un contexto se cancela, el comportamiento correcto es aburrido pero importante: dejar de hacer trabajo, dejar de esperar E/S lenta y devolver un error claro. Si un handler está esperando una consulta a la base de datos y el contexto acaba, devuelve rápido y deja que la llamada a la BD se aborte si soporta contexto.
El lugar más seguro para parar peticiones lentas es el límite donde entra el tráfico a tu servicio. Si una petición va a expirar, quieres que pase de forma predecible y temprana, no después de que haya ocupado goroutines, conexiones a BD y memoria.
Empieza en el borde (load balancer, API gateway, reverse proxy) y fija un tope duro de cuánto puede vivir cualquier petición. Eso protege tu servicio Go incluso si un handler olvida poner un timeout.
Dentro de tu servidor Go, configura timeouts HTTP para que el servidor no espere para siempre a un cliente lento o a una respuesta estancada. Como mínimo, configura timeouts para leer cabeceras, leer el cuerpo completo de la petición, escribir la respuesta y mantener conexiones inactivas.
Elige un presupuesto por defecto que encaje con tu producto. Para muchas APIs, 1 a 3 segundos es un punto de partida razonable para peticiones típicas, con un límite mayor para operaciones sabidas como exportaciones. El número exacto importa menos que ser consistente, medirlo y tener una regla clara para excepciones.
Las respuestas por streaming requieren cuidado extra. Es fácil crear un stream accidentalmente infinito donde el servidor mantiene la conexión abierta y escribe pequeños fragmentos para siempre, o espera eternamente antes del primer byte. Decide desde el principio si un endpoint es realmente un stream. Si no lo es, aplica un tiempo máximo total y un tiempo máximo hasta el primer byte.
Una vez que el borde tiene un deadline claro, es mucho más fácil propagar ese deadline por toda la petición.
El lugar más simple para empezar es el handler HTTP. Es donde entra una petición a tu sistema, así que es natural poner un límite duro.
Crea un nuevo contexto con un deadline y asegúrate de cancelarlo. Luego pasa ese contexto a todo lo que pueda bloquear: trabajo de base de datos, llamadas HTTP o cálculos lentos.
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)
}
Una buena regla: si una función puede esperar E/S, debería aceptar un context.Context. Mantén los handlers legibles moviendo detalles a helpers pequeños como loadUser.
func (s *Server) loadUser(ctx context.Context, id string) (User, error) {
return s.repo.GetUser(ctx, id) // repo should use QueryRowContext/ExecContext
}
Si se alcanza el deadline (o el cliente se desconecta), para el trabajo y devuelve una respuesta amigable. Un mapeo común es context.DeadlineExceeded a 504 Gateway Timeout, y context.Canceled a "cliente se fue" (a menudo sin cuerpo de respuesta).
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 went away. Avoid doing more work.
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
}
Este patrón evita acumulaciones. Cuando el temporizador expira, todas las funciones conscientes del contexto en la cadena reciben la misma señal de parada y pueden salir rápido.
Una vez que tu handler tiene un contexto con deadline, la regla más importante es simple: usa ese mismo ctx hasta la llamada a la base de datos. Así es como los timeouts detienen trabajo en lugar de solo evitar que tu handler espere.
Con database/sql, prefiere los métodos conscientes del contexto:
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
}
}
Si el presupuesto del handler es de 2 segundos, la base de datos debería recibir solo una porción de eso. Deja tiempo para codificar JSON, otras dependencias y manejo de errores. Un punto de partida sencillo es dar a Postgres entre el 30% y el 60% del presupuesto total. Con un deadline de handler de 2 segundos, eso puede ser 800ms a 1.2s.
Cuando el contexto se cancela, el driver pide a Postgres que pare la consulta. Normalmente la conexión vuelve al pool y puede reusarse. Si la cancelación ocurre durante un problema de red, el driver puede descartar la conexión y abrir una nueva más tarde. En cualquier caso, evitas una goroutine esperando para siempre.
Al comprobar errores, trata los timeouts diferente a fallos reales de BD. Si errors.Is(err, context.DeadlineExceeded), te quedaste sin tiempo y deberías devolver un timeout. Si errors.Is(err, context.Canceled), el cliente se fue y deberías parar calladamente. Otros errores son problemas normales de la consulta (SQL incorrecto, fila faltante, permisos).
Si tu handler tiene un deadline, tus llamadas HTTP salientes también deberían respetarlo. De lo contrario, el cliente se rinde, pero tu servidor sigue esperando a un upstream lento y ata goroutines, sockets y memoria.
Construye las solicitudes salientes con el contexto padre para que la cancelación viaje automáticamente:
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)
}
Ese timeout por llamada es una red de seguridad. El deadline padre sigue siendo el jefe real. Un reloj para toda la petición, más límites menores para pasos arriesgados.
Además, configura timeouts a nivel de transporte. El contexto cancela la petición, pero los timeouts del transport te protegen de handshakes lentos y servidores que nunca envían cabeceras.
Un detalle que atrapa a los equipos: los cuerpos de respuesta deben cerrarse en todos los caminos. Si devuelves temprano (comprobación de código de estado, error al decodificar JSON, timeout de contexto), cierra igualmente el body. Filtrar cuerpos puede agotar silenciosamente las conexiones del pool y convertirse en picos de latencia "aleatorios".
Un escenario concreto: tu API llama a un proveedor de pagos. El cliente caduca tras 2 segundos, pero el upstream se queda colgado 30 segundos. Sin cancelación de la petición y timeouts en el transport, sigues pagando esos 30 segundos de espera por cada petición abandonada.
Una petición normalmente toca más de una cosa lenta: trabajo del handler, una consulta a la BD y una o más APIs externas. Si das a cada paso un timeout generoso, el tiempo total crece silenciosamente hasta que los usuarios lo notan y tu servidor se acumula.
El presupuestado es la solución más simple. Establece un deadline padre para toda la petición y luego da a cada dependencia un trozo más pequeño. Los deadlines hijos deberían ser anteriores al padre para que falles rápido y aún tengas tiempo para devolver un error limpio.
Reglas empíricas que funcionan en servicios reales:
Evita apilar timeouts que se peleen entre sí. Si tu contexto de handler tiene 2 segundos y tu cliente HTTP tiene 10 segundos, estás seguro pero es confuso. Si es al revés, el cliente puede cortar temprano por motivos ajenos.
Para trabajo en segundo plano (logs de auditoría, métricas, emails), no reutilices el contexto de la petición. Usa un contexto separado con su propio timeout corto para que las cancelaciones del cliente no maten limpiezas importantes.
La mayoría de bugs de timeouts no están en el handler. Suceden una o dos capas abajo, donde el deadline se pierde silenciosamente. Si pones timeouts en el borde pero los ignoras en el medio, todavía puedes acabar con goroutines, consultas a BD o llamadas HTTP que siguen ejecutándose después de que el cliente se fue.
Los patrones que aparecen con más frecuencia son simples:
context.Background() (o TODO). Eso desconecta el trabajo de la cancelación del cliente y del deadline del handler.ctx.Done(). La petición se canceló, pero tu código sigue esperando.context.WithTimeout. Acabas con muchos temporizadores y deadlines confusos.ctx a llamadas bloqueantes (consultas DB, HTTP saliente, publicación de mensajes). Un timeout en el handler no hace nada si la llamada a la dependencia lo ignora.Un fallo clásico: agregas un timeout de 2 segundos en el handler, y luego tu repositorio usa context.Background() para la consulta a la BD. Bajo carga, una consulta lenta sigue ejecutándose incluso después de que el cliente se rindió, y la acumulación crece.
Arregla lo básico: pasa ctx como primer argumento por toda la pila. Dentro de trabajos largos, añade comprobaciones rápidas como select { case <-ctx.Done(): return ctx.Err() default: }. Mapea context.DeadlineExceeded a una respuesta de timeout (a menudo 504) y context.Canceled a una respuesta de cancelación de cliente (a menudo 408 o 499 según tu convención).
Los timeouts solo ayudan si puedes ver que ocurren y confirmar que el sistema se recupera limpiamente. Cuando algo es lento, la petición debe pararse, los recursos liberarse y la API seguir respondiendo.
Para cada petición, registra el mismo pequeño conjunto de campos para que puedas comparar peticiones normales vs timeouts. Incluye el deadline del contexto (si existe) y qué terminó el trabajo.
Campos útiles incluyen el deadline (o "none"), el tiempo transcurrido total, la razón de cancelación (timeout vs cliente cancelado), una etiqueta corta de operación ("db.query users", "http.call billing") y un request ID.
Un patrón mínimo se ve así:
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)
Los logs te ayudan a depurar una petición. Las métricas muestran tendencias.
Sigue unas pocas señales que suelen subir temprano cuando los timeouts están mal: conteo de timeouts por ruta y dependencia, peticiones en vuelo (debería estabilizarse bajo carga), tiempo de espera en el pool de BD y percentiles de latencia (p95/p99) divididos por éxito vs timeout.
Haz la lentitud predecible. Añade un delay solo para debug en un handler, ralentiza una consulta SQL con una espera deliberada o envuelve una llamada externa con un servidor de prueba que duerme. Luego verifica dos cosas: ves el error de timeout, y el trabajo se detiene poco después de la cancelación.
Un pequeño test de carga ayuda también. Ejecuta 20 a 50 peticiones concurrentes durante 30 a 60 segundos con una dependencia forzada lenta. El conteo de goroutines y las peticiones en vuelo deberían subir y luego estabilizarse. Si siguen subiendo, algo está ignorando la cancelación del contexto.
Los timeouts solo ayudan si se aplican en todas partes donde una petición puede esperar. Antes de deployar, haz una pasada por el código y confirma que se siguen las mismas reglas en cada handler.
context.DeadlineExceeded y context.Canceled.http.NewRequestWithContext (o req = req.WithContext(ctx)) y el cliente tiene timeouts de transport (dial, TLS, cabecera de respuesta). Evita confiar en http.DefaultClient en rutas de producción.Un pequeño ejercicio de "dependencia lenta" antes de la release vale la pena. Añade un delay artificial de 2 segundos a una consulta SQL y confirma tres cosas: el handler devuelve a tiempo, la llamada a la BD realmente se detiene (no solo el handler), y tus logs dicen claramente que fue un timeout de BD.
Imagina un endpoint como GET /v1/account/summary. Una acción del usuario dispara tres cosas: una consulta PostgreSQL (cuenta más actividad reciente) y dos llamadas HTTP externas (por ejemplo, verificación de estado de facturación y una búsqueda de enriquecimiento de perfil).
Da a toda la petición un presupuesto duro de 2 segundos. Sin presupuesto, una dependencia lenta puede mantener goroutines, conexiones a BD y memoria ocupadas hasta que tu API empiece a hacer timeouts por todas partes.
Un reparto simple podría ser 800ms para la consulta de BD, 600ms para la llamada externa A y 600ms para la llamada externa B.
Una vez que conoces el deadline global, pásalo hacia abajo. Cada dependencia recibe su propio timeout más pequeño, pero hereda la cancelación del padre.
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.
}
Si la llamada externa B se ralentiza y tarda 2.5 segundos, tu handler debería dejar de esperar a los 600ms, cancelar el trabajo en vuelo y devolver una respuesta de timeout clara al cliente. El cliente ve un fallo rápido en lugar de un spinner colgado.
Tus logs deberían dejar claro qué consumió el presupuesto, por ejemplo: la BD terminó rápido, externa A tuvo éxito, externa B alcanzó su límite y devolvió context deadline exceeded.
Una vez que un endpoint real funcione bien con timeouts y cancelación, conviértelo en un patrón repetible. Aplica el flujo de extremo a extremo: deadline en el handler, llamadas a BD y HTTP salientes. Luego copia la misma estructura al siguiente endpoint.
Avanzarás más rápido si centralizas las partes aburridas: un helper de timeout en el borde, wrappers que aseguren que ctx se pasa a BD y HTTP, y un único mapeo de errores y formato de logs consistente.
Si quieres prototipar este patrón rápidamente, Koder.ai (koder.ai) puede generar handlers Go y llamadas de servicio a partir de un prompt de chat, y puedes exportar el código fuente para aplicar tus propios helpers de timeout y presupuestos. El objetivo es consistencia: las llamadas lentas se detienen pronto, los errores se parecen entre sí y la depuración no depende de quién escribió el endpoint.
Una petición lenta retiene recursos limitados mientras espera: una goroutine, memoria para buffers y objetos de respuesta, y con frecuencia una conexión a la base de datos o una conexión HTTP. Cuando suficientes peticiones se quedan esperando al mismo tiempo, se forman colas, la latencia sube para todo el tráfico y el servicio puede fallar incluso si cada petición terminaría en algún momento.
Establece un deadline claro en el límite de la petición (proxy/gateway y en el servidor Go), deriva un contexto con tiempo en el handler y pasa ese ctx a cada llamada bloqueante (base de datos y HTTP saliente). Cuando se alcanza el deadline, devuelve rápido con una respuesta de timeout consistente y detén cualquier trabajo en vuelo que soporte cancelación.
Usa context.WithTimeout(parent, d) cuando quieras “parar después de esta duración”, que es lo más común en handlers. Usa context.WithDeadline(parent, t) cuando ya tienes un tiempo de corte fijo que hay que respetar. Usa context.WithCancel(parent) cuando alguna condición interna deba detener el trabajo antes (por ejemplo, “ya tenemos la respuesta” o “el cliente se desconectó”).
Siempre llama a la función cancel, típicamente con defer cancel() justo después de crear el contexto derivado. Cancelar libera el temporizador y permite que cualquier trabajo hijo reciba una señal clara de parada, especialmente en rutas que retornan temprano antes de que el deadline ocurra.
Crea el contexto de la petición una sola vez en el handler y pásalo hacia abajo como primer argumento a las funciones que pueden bloquearse. Una comprobación rápida es buscar context.Background() o context.TODO() en las rutas de petición; esos a menudo rompen la propagación de la cancelación desconectando el trabajo del deadline de la petición.
Usa métodos con soporte de contexto como QueryContext, QueryRowContext y ExecContext (o los equivalentes de tu driver). Cuando el contexto termina, el driver puede pedir a PostgreSQL que cancele la consulta para que no sigas consumiendo tiempo y conexiones después de que la petición haya terminado.
Adjunta el contexto padre de la petición a la solicitud saliente usando http.NewRequestWithContext(ctx, ...), y además configura timeouts en el cliente/transport para protegerte durante la conexión, TLS y la espera de cabeceras de respuesta. Incluso en errores o respuestas no-200, siempre cierra el cuerpo de la respuesta para que las conexiones vuelvan al pool.
Primero elige un presupuesto total para la petición, luego asigna a cada dependencia una porción menor que quepa dentro de él, dejando un pequeño colchón para la sobrecarga del handler y la codificación de la respuesta. Si el contexto padre solo tiene poco tiempo, evita iniciar trabajo caro que no pueda terminar antes del deadline.
Un mapeo común es tratar context.DeadlineExceeded como 504 Gateway Timeout con un mensaje corto como “request timed out”. Para context.Canceled, suele significar que el cliente se desconectó; a menudo la mejor acción es detener el trabajo y no escribir cuerpo, para no malgastar recursos adicionales.
Los errores más frecuentes son usar context.Background() y perder el contexto de la petición, iniciar reintentos o sleeps sin comprobar ctx.Done(), y olvidar adjuntar ctx a llamadas bloqueantes. Otro problema sutil es apilar muchos timeouts no relacionados por todas partes, lo que hace que las fallas sean difíciles de razonar y puede causar cortes tempranos sorpresa.