Context en Go: timeouts, cancelaciones y peticiones robustas

context.Context en Go explicado: cancelaciones, timeouts, propagación en HTTP, base de datos y workers. Pieza clave para servicios reales.

Cover for Context en Go: timeouts, cancelaciones y peticiones robustas

Context en Go me confundió durante semanas. Lo veía en todas las firmas de función, lo pasaba mecánicamente como primer argumento, y no entendía por qué existía. Parecía una formalidad del lenguaje, algo que tenías que poner porque sí. Entonces construí un servicio que por cada petición llamaba a tres APIs externas y hacía una consulta a PostgreSQL, y de repente todo encajó. Si una de esas llamadas tardaba 30 segundos, las otras tres seguían esperando. Si el cliente cerraba la conexión, el servidor seguía procesando una respuesta que nadie iba a recibir. Sin context, tu servicio no tiene forma de decir “para, esto ya no importa”.

No es una pieza secundaria. Es una de las abstracciones más importantes de Go para cualquier cosa que vaya más allá de un script.


Qué es context.Context: la interfaz

context.Context es una interfaz con cuatro métodos:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}
  • Deadline() devuelve el momento en que el contexto expirará, si tiene uno definido.
  • Done() devuelve un canal que se cierra cuando el contexto se cancela o expira. Es la pieza clave: puedes hacer select sobre este canal para reaccionar a la cancelación.
  • Err() devuelve nil mientras el contexto esté activo, context.Canceled si fue cancelado manualmente, o context.DeadlineExceeded si expiró el timeout.
  • Value(key) devuelve valores asociados al contexto. Úsalo con cautela (más adelante explico por qué).

Lo que hace especial a context.Context es que es inmutable y jerárquico. Nunca modificas un contexto existente: creas uno nuevo derivado del anterior. Si cancelas un contexto padre, todos los hijos se cancelan automáticamente. Esta propiedad es la que permite propagar señales de cancelación a través de toda la cadena de llamadas de tu aplicación.


context.Background() y context.TODO()

Estos son los dos contextos raíz que ofrece la librería estándar:

ctx := context.Background()
ctx := context.TODO()

context.Background() es el contexto vacío por defecto. No tiene deadline, no se puede cancelar, no tiene valores. Lo usas como punto de partida cuando no hay otro contexto disponible: en main(), en la inicialización de tu aplicación, o al arrancar un worker de fondo.

context.TODO() es funcionalmente idéntico a Background(), pero con una intención diferente: marca un lugar donde sabes que debería haber un contexto real pero todavía no lo has implementado. Es un recordatorio en el código, no una solución permanente.

func main() {
    ctx := context.Background()
    server := NewServer(ctx)
    server.Start()
}

En la práctica, si ves context.TODO() en un codebase productivo, es una señal de que alguien dejó algo a medias. Trátalo como deuda técnica.


context.WithCancel: cancelación manual

context.WithCancel crea un contexto derivado que puedes cancelar explícitamente llamando a una función:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go func() {
    // trabajo largo
    select {
    case <-ctx.Done():
        fmt.Println("cancelado:", ctx.Err())
        return
    case result := <-doWork():
        fmt.Println("resultado:", result)
    }
}()

// En algún punto decides cancelar
cancel()

La función cancel es idempotente: puedes llamarla múltiples veces sin problema. Y siempre debes llamarla, incluso si el trabajo termina antes. Si no la llamas, el contexto y sus recursos internos quedan en memoria hasta que el padre se cancele o el programa termine. El defer cancel() inmediatamente después de crear el contexto es un patrón obligatorio.

Un caso real donde necesitas cancelación manual: tienes varias goroutines haciendo la misma consulta a diferentes réplicas de base de datos. La primera que responda gana, y cancelas las demás.

func queryFastest(ctx context.Context, replicas []string, query string) (Result, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    results := make(chan Result, len(replicas))
    for _, replica := range replicas {
        go func(addr string) {
            result, err := queryReplica(ctx, addr, query)
            if err == nil {
                results <- result
            }
        }(replica)
    }

    select {
    case r := <-results:
        return r, nil
    case <-ctx.Done():
        return Result{}, ctx.Err()
    }
}

Cuando la primera goroutine envía su resultado y la función retorna, defer cancel() cancela el contexto. Las goroutines que todavía estaban esperando respuesta de sus réplicas ven el canal ctx.Done() cerrarse y pueden limpiar recursos.


context.WithTimeout: deadline automático

context.WithTimeout es probablemente la variante que más vas a usar. Crea un contexto que se cancela automáticamente después de una duración:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

result, err := callExternalAPI(ctx)
if err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        log.Println("la API tardó más de 5 segundos")
    }
    return err
}

La diferencia con WithCancel es que no necesitas decidir cuándo cancelar. Defines el tiempo máximo y Go se encarga del resto. Pero sigue siendo obligatorio llamar a cancel con defer. Si la operación termina en 100ms pero el timeout era de 5 segundos, sin defer cancel() el timer interno sigue vivo durante los 4.9 segundos restantes, consumiendo recursos.

Lo que muchos no entienden al principio: el timeout se aplica a toda la cadena de operaciones que usen ese contexto. Si pasas un contexto con timeout de 5 segundos a una función que hace tres llamadas HTTP secuenciales, las tres comparten esos 5 segundos. No son 5 segundos para cada una.

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()

// Las tres llamadas comparten los 5 segundos
user, err := fetchUser(ctx, userID)          // tarda 2s
orders, err := fetchOrders(ctx, userID)      // tarda 2s
recommendations, err := fetchRecs(ctx, user) // le quedan ~1s, probablemente falle

Esto es intencional. El timeout representa el presupuesto de tiempo total para la operación, no para cada paso individual. Si necesitas timeouts individuales, crea contextos derivados:

ctx, cancel := context.WithTimeout(ctx, 10*time.Second) // global
defer cancel()

userCtx, userCancel := context.WithTimeout(ctx, 3*time.Second) // máx 3s para user
defer userCancel()
user, err := fetchUser(userCtx, userID)

ordersCtx, ordersCancel := context.WithTimeout(ctx, 3*time.Second) // máx 3s para orders
defer ordersCancel()
orders, err := fetchOrders(ordersCtx, userID)

context.WithDeadline: límite de tiempo absoluto

context.WithDeadline funciona igual que WithTimeout, pero en lugar de una duración, especificas un momento exacto:

deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

En la práctica, WithTimeout es azúcar sintáctico sobre WithDeadline. Internamente, context.WithTimeout(parent, d) es equivalente a context.WithDeadline(parent, time.Now().Add(d)).

¿Cuándo usarías WithDeadline directamente? Cuando el deadline viene de fuera. Por ejemplo, si recibes una cabecera HTTP que te dice “esta petición debe completarse antes de las 14:30:05 UTC”, usas WithDeadline con ese valor absoluto.

Hay un detalle importante: un hijo no puede extender el deadline de su padre. Si el padre expira en 5 segundos y creas un hijo con WithTimeout(parent, 10*time.Second), el hijo seguirá expirando en 5 segundos. El contexto más restrictivo siempre gana.

parent, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Esto NO da 10 segundos. El hijo hereda el deadline del padre (5s).
child, childCancel := context.WithTimeout(parent, 10*time.Second)
defer childCancel()

Puedes comprobar el deadline efectivo:

if deadline, ok := ctx.Deadline(); ok {
    remaining := time.Until(deadline)
    fmt.Printf("quedan %v\n", remaining)
}

Propagando context a través de tu aplicación

La regla fundamental: context siempre es el primer parámetro de una función y se llama ctx. No es una convención estética, es el estándar del ecosistema Go entero.

// Correcto
func GetUser(ctx context.Context, id int64) (*User, error)

// Incorrecto
func GetUser(id int64, ctx context.Context) (*User, error)

// Incorrecto: nunca guardes context en un struct
type Service struct {
    ctx context.Context // NO hagas esto
}

Guardar un contexto en un struct es un error que parece cómodo pero rompe el modelo de propagación. Un contexto está ligado a una operación concreta (una petición HTTP, una ejecución de un job). Si lo guardas en un struct, ese struct queda atado a una operación que ya terminó, o peor, compartes un contexto entre operaciones diferentes.

La propagación correcta es lineal: cada capa de tu aplicación recibe el contexto y lo pasa a la siguiente.

// Handler → Service → Repository

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    user, err := h.service.FindUser(ctx, userID)
    // ...
}

func (s *Service) FindUser(ctx context.Context, id int64) (*User, error) {
    return s.repo.GetByID(ctx, id)
}

func (r *Repository) GetByID(ctx context.Context, id int64) (*User, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
    // ...
}

Si el cliente cierra la conexión HTTP, r.Context() se cancela, la cancelación se propaga al servicio, del servicio al repositorio, y la consulta a la base de datos se aborta. Todo sin que tengas que escribir lógica explícita de cancelación en cada capa. El contexto lo hace por ti.


Context en HTTP handlers: contexto por petición

Cada petición HTTP en Go lleva su propio contexto. Lo obtienes con r.Context():

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Este contexto se cancela automáticamente cuando:
    // 1. El cliente cierra la conexión
    // 2. El servidor cancela la petición (por ejemplo, por timeout del server)

    result, err := processRequest(ctx)
    if err != nil {
        if errors.Is(err, context.Canceled) {
            // El cliente se fue, no tiene sentido escribir respuesta
            return
        }
        http.Error(w, "error interno", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(result)
}

Si usas un framework o middleware que añade timeouts, estos se aplican al contexto de la petición. Es habitual tener un middleware que envuelve cada petición con un timeout máximo:

func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), timeout)
            defer cancel()
            r = r.WithContext(ctx)
            next.ServeHTTP(w, r)
        })
    }
}

Con r.WithContext(ctx) creas una copia de la petición con el nuevo contexto. Todas las funciones downstream que usen r.Context() verán el timeout aplicado.

Un detalle que genera bugs sutiles: si tu handler lanza una goroutine para trabajo en segundo plano, no uses r.Context() para esa goroutine. Cuando la petición HTTP termina, el contexto se cancela, y tu goroutine de fondo también se cancela.

func handler(w http.ResponseWriter, r *http.Request) {
    // MAL: esta goroutine se cancela cuando termina la petición HTTP
    go sendAnalytics(r.Context(), event)

    // BIEN: contexto independiente para trabajo en segundo plano
    go sendAnalytics(context.Background(), event)

    w.WriteHeader(http.StatusOK)
}

Context con consultas a base de datos: pgx y database/sql

La librería estándar database/sql soporta contexto en todas sus operaciones. Si usas pgx (el driver PostgreSQL más usado en Go), el soporte es incluso mejor.

Con database/sql:

func (r *Repository) GetUser(ctx context.Context, id int64) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"

    var user User
    err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, fmt.Errorf("timeout consultando usuario %d: %w", id, err)
        }
        return nil, fmt.Errorf("error consultando usuario %d: %w", id, err)
    }

    return &user, nil
}

Con pgx directamente:

func (r *Repository) GetUser(ctx context.Context, id int64) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"

    var user User
    err := r.pool.QueryRow(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return nil, fmt.Errorf("error consultando usuario %d: %w", id, err)
    }

    return &user, nil
}

pgx usa el contexto como primer parámetro en lugar de tener métodos separados *Context. Es un diseño más limpio.

Lo clave aquí: cuando el contexto se cancela, la base de datos recibe una señal para abortar la consulta. No es solo que tu código Go deja de esperar la respuesta; PostgreSQL realmente cancela la query en el servidor. Esto es fundamental para consultas pesadas. Sin contexto, una query que tarda 60 segundos sigue consumiendo recursos en el servidor de base de datos aunque el cliente ya se haya ido.

Transacciones con contexto:

func (r *Repository) TransferFunds(ctx context.Context, from, to int64, amount float64) error {
    tx, err := r.db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
    if err != nil {
        return err
    }

    _, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
    if err != nil {
        return err
    }

    return tx.Commit()
}

Si el contexto se cancela entre los dos UPDATE, la transacción se aborta y hace rollback. No te quedas con un estado inconsistente. Esto es algo que en otros lenguajes necesitas implementar manualmente; en Go, propagar el contexto te lo da gratis.


Context en goroutines y workers

Los worker pools en Go necesitan context para dos cosas: saber cuándo parar y respetar los timeouts del sistema.

Un worker básico que respeta la cancelación:

func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d: parando (%v)\n", id, ctx.Err())
            return
        case job, ok := <-jobs:
            if !ok {
                return // canal cerrado, no hay más trabajo
            }
            result := process(ctx, job)
            select {
            case results <- result:
            case <-ctx.Done():
                return
            }
        }
    }
}

Un pool de workers con cancelación graceful:

func RunWorkerPool(ctx context.Context, numWorkers int, jobs <-chan Job) []Result {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    results := make(chan Result, numWorkers)
    var wg sync.WaitGroup

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            worker(ctx, id, jobs, results)
        }(i)
    }

    // Cerrar results cuando todos los workers terminen
    go func() {
        wg.Wait()
        close(results)
    }()

    var collected []Result
    for r := range results {
        collected = append(collected, r)
    }

    return collected
}

El patrón es siempre el mismo: el select con ctx.Done() en cualquier punto donde el worker pueda bloquearse. Si solo lo pones al recibir el job pero no al enviar el resultado, el worker puede quedarse bloqueado en results <- result si nadie está leyendo el canal.

Para tareas periódicas (cron-like), el contexto controla cuándo parar el loop:

func periodicTask(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := doWork(ctx); err != nil {
                log.Printf("error en tarea periódica: %v", err)
            }
        }
    }
}

Sin context, no tienes una forma limpia de decirle a este loop que pare. Acabas usando flags booleanas atómicas o canales manuales, que es exactamente lo que context abstrae.


Context values: cuándo usarlos (pocas veces) y cuándo no

context.WithValue permite adjuntar pares clave-valor a un contexto:

type contextKey string

const requestIDKey contextKey = "requestID"

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey, id)
}

func GetRequestID(ctx context.Context) string {
    id, ok := ctx.Value(requestIDKey).(string)
    if !ok {
        return ""
    }
    return id
}

El tipo contextKey privado es obligatorio. Si usas string directamente como clave, cualquier paquete que use la misma string puede colisionar. Con un tipo propio no exportado, solo tu paquete puede acceder al valor.

Cuándo tiene sentido usar context.WithValue:

  • Request IDs y trace IDs para logging y observabilidad.
  • Información de autenticación (el usuario autenticado de la petición).
  • Metadatos que cruzan boundaries entre paquetes y que no pertenecen a la firma de la función.

Cuándo no usarlo:

  • Parámetros de negocio. Si una función necesita un userID, ponlo como parámetro explícito. No lo escondas en el contexto.
  • Dependencias. Nunca metas un logger, una conexión a base de datos o un servicio en el contexto.
  • Cualquier cosa que la función necesite para funcionar correctamente. Si sin ese valor la función falla, debería ser un parámetro obligatorio con tipo concreto.

La regla práctica: si quitas el valor del contexto y la función debería seguir compilando y funcionando (quizás con menos información en los logs), entonces está bien en el contexto. Si sin el valor la función no puede hacer su trabajo, es un parámetro de la función.

Un middleware típico que inyecta valores en el contexto:

func RequestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.New().String()
        }
        ctx := WithRequestID(r.Context(), id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Y downstream:

func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    reqID := GetRequestID(r.Context())
    log.Printf("[%s] procesando GetUser", reqID)
    // ...
}

Errores comunes: ignorar context y no propagarlo

He visto estos patrones más veces de las que me gustaría:

1. Recibir context y no usarlo

// MAL: acepta ctx pero usa http.DefaultClient (que ignora el contexto)
func fetchData(ctx context.Context, url string) ([]byte, error) {
    resp, err := http.Get(url) // http.Get no usa tu ctx
    return io.ReadAll(resp.Body)
}

// BIEN: usa el contexto en la petición
func fetchData(ctx context.Context, url string) ([]byte, error) {
    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()
    return io.ReadAll(resp.Body)
}

http.Get crea una petición sin contexto. Si usas http.Get en una función que recibe ctx, estás mintiendo en la firma: prometes que respetas la cancelación pero no lo haces. Siempre usa http.NewRequestWithContext.

2. No propagar context entre capas

// MAL: el service crea su propio context, ignorando el del handler
func (s *Service) Process(ctx context.Context, data Data) error {
    dbCtx := context.Background() // ¿Por qué? Esto ignora el timeout del handler
    return s.repo.Save(dbCtx, data)
}

// BIEN: propaga el contexto del handler
func (s *Service) Process(ctx context.Context, data Data) error {
    return s.repo.Save(ctx, data)
}

Si creas un context.Background() nuevo en medio de la cadena, rompes toda la propagación. El handler puede tener un timeout de 10 segundos, pero tu repositorio no lo sabe porque le pasaste un contexto sin deadline.

3. No comprobar la cancelación en loops

// MAL: si ctx se cancela, este loop sigue procesando miles de items
func processAll(ctx context.Context, items []Item) error {
    for _, item := range items {
        if err := process(item); err != nil {
            return err
        }
    }
    return nil
}

// BIEN: comprueba cancelación periódicamente
func processAll(ctx context.Context, items []Item) error {
    for _, item := range items {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
        if err := process(item); err != nil {
            return err
        }
    }
    return nil
}

El select con default es no-bloqueante: comprueba si el contexto está cancelado y, si no, continúa inmediatamente. El coste es mínimo pero te ahorra procesar miles de items innecesarios cuando la petición ya no importa.

4. Olvidar defer cancel()

// MAL: leak del timer interno
func doSomething() {
    ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
    callAPI(ctx)
}

// BIEN
func doSomething() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    callAPI(ctx)
}

El linter go vet te avisará de esto. Si no tienes linting configurado, configúralo. Es demasiado fácil olvidar el cancel.


Ejemplo práctico: HTTP handler → service → repository con context

Vamos a juntar todo en un ejemplo realista. Un endpoint que busca un usuario, consulta sus pedidos, y devuelve una respuesta combinada. El handler aplica un timeout global, y cada operación respeta la cancelación.

Empezamos con el repositorio:

package repository

import (
    "context"
    "database/sql"
    "fmt"
)

type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) GetByID(ctx context.Context, id int64) (*User, error) {
    query := "SELECT id, name, email FROM users WHERE id = $1"
    var user User
    err := r.db.QueryRowContext(ctx, query, id).Scan(&user.ID, &user.Name, &user.Email)
    if err != nil {
        return nil, fmt.Errorf("user %d: %w", id, err)
    }
    return &user, nil
}

type OrderRepository struct {
    db *sql.DB
}

func NewOrderRepository(db *sql.DB) *OrderRepository {
    return &OrderRepository{db: db}
}

func (r *OrderRepository) GetByUserID(ctx context.Context, userID int64) ([]Order, error) {
    query := "SELECT id, product, amount FROM orders WHERE user_id = $1 ORDER BY id DESC LIMIT 10"
    rows, err := r.db.QueryContext(ctx, query, userID)
    if err != nil {
        return nil, fmt.Errorf("orders for user %d: %w", userID, err)
    }
    defer rows.Close()

    var orders []Order
    for rows.Next() {
        var o Order
        if err := rows.Scan(&o.ID, &o.Product, &o.Amount); err != nil {
            return nil, err
        }
        orders = append(orders, o)
    }
    return orders, rows.Err()
}

El servicio orquesta las dos consultas. Si alguna falla o el contexto se cancela, devuelve error:

package service

import (
    "context"
    "fmt"
)

type UserService struct {
    users  *repository.UserRepository
    orders *repository.OrderRepository
}

func NewUserService(users *repository.UserRepository, orders *repository.OrderRepository) *UserService {
    return &UserService{users: users, orders: orders}
}

func (s *UserService) GetUserWithOrders(ctx context.Context, userID int64) (*UserProfile, error) {
    user, err := s.users.GetByID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("fetching user: %w", err)
    }

    orders, err := s.orders.GetByUserID(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("fetching orders: %w", err)
    }

    return &UserProfile{
        User:   *user,
        Orders: orders,
    }, nil
}

Y el handler HTTP, que aplica un timeout de 5 segundos a toda la operación:

package handler

import (
    "context"
    "encoding/json"
    "errors"
    "log"
    "net/http"
    "strconv"
    "time"
)

type UserHandler struct {
    service *service.UserService
}

func NewUserHandler(service *service.UserService) *UserHandler {
    return &UserHandler{service: service}
}

func (h *UserHandler) GetUserProfile(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    reqID := GetRequestID(ctx) // del middleware

    userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
    if err != nil {
        http.Error(w, "id inválido", http.StatusBadRequest)
        return
    }

    profile, err := h.service.GetUserWithOrders(ctx, userID)
    if err != nil {
        switch {
        case errors.Is(err, context.DeadlineExceeded):
            log.Printf("[%s] timeout obteniendo perfil de usuario %d", reqID, userID)
            http.Error(w, "timeout", http.StatusGatewayTimeout)
        case errors.Is(err, context.Canceled):
            // El cliente se desconectó, no hace falta responder
            log.Printf("[%s] cliente desconectado para usuario %d", reqID, userID)
        default:
            log.Printf("[%s] error obteniendo perfil de usuario %d: %v", reqID, userID, err)
            http.Error(w, "error interno", http.StatusInternalServerError)
        }
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(profile)
}

Observa cómo funciona el flujo:

  1. El handler crea un contexto con timeout de 5 segundos, derivado del contexto de la petición HTTP.
  2. Ese mismo contexto se pasa al servicio, que lo pasa a los repositorios.
  3. Los repositorios lo pasan a database/sql, que lo usa para limitar las queries.
  4. Si la base de datos tarda más de 5 segundos en total (sumando ambas queries), el contexto expira y se devuelve un 504.
  5. Si el cliente cierra la conexión, r.Context() se cancela, lo que cancela el contexto hijo con timeout, lo que cancela las queries.

Cada capa hace una sola cosa: recibir el contexto, usarlo, propagarlo. No hay flags booleanos de “shouldStop”, no hay canales manuales de “done”. El contexto lo gestiona todo.


Cuándo no necesitas context

No todo necesita un contexto. Funciones puras que hacen cálculos, transformaciones de datos en memoria, validaciones… si la operación no puede bloquearse y no interactúa con I/O, no necesita ctx context.Context. Añadirlo por sistema solo contamina tu API.

La pregunta es simple: ¿esta función puede bloquearse esperando algo externo (red, disco, canal)? Si sí, necesita context. Si no, no lo necesita.

// No necesita context: es un cálculo en memoria
func CalculateDiscount(price float64, percentage int) float64 {
    return price * (1 - float64(percentage)/100)
}

// Sí necesita context: hace I/O
func FetchPrice(ctx context.Context, productID string) (float64, error) {
    // ...
}

La ceremonia que te salva en producción

Cuando empecé con Go, context me parecía excesivamente ceremonial. Siempre primer parámetro, siempre llamado ctx, siempre defer cancel() al crear contextos con WithCancel, WithTimeout o WithDeadline. Nunca guardarlo en un struct, propagarlo a todas las operaciones de I/O, comprobar ctx.Done() en loops y goroutines. Y context.WithValue solo para metadatos transversales como request ID o trace ID, nunca para parámetros de negocio.

Esa opinión cambió cuando tuve un servicio con 20 endpoints, llamando a 5 APIs externas con una base de datos detrás. Cada petición tiene un mecanismo automático de “para todo si esto ya no importa”, y eso es exactamente lo que necesitas cuando un cliente se desconecta a mitad de una cadena de operaciones. Sin context, habrías tenido que inventar flags booleanos, canales manuales de done, o simplemente dejar que las operaciones terminaran desperdiciando recursos. Es una de esas decisiones de diseño de Go que no aprecias hasta que te ahorra un incidente a las tres de la madrugada.

Para verlo en acción con APIs reales, revisa el artículo sobre cómo construir una API REST con Go. Si quieres profundizar en cómo context interactúa con concurrencia en Go o con PostgreSQL con Go, esos artículos cubren los detalles específicos de cada caso.

OshyTech

Ingeniería backend y de datos orientada a sistemas escalables, automatización e IA.

Navegación

Copyright 2026 OshyTech. Todos los derechos reservados