Dev.to WebDev 🛠 Dev 👁 1 📖 14 min read

Go 1.27: cómo funciona la reutilización de conexiones HTTP

Un programa en Go que solo pide el código de estado de una URL, cien veces seguidas, tardaba 710 milisegundos en Go 1.26. Con Go 1.27, el mismo programa, sin cambiar una sola línea, tarda unos 150 milisegundos: casi cinc

Un programa en Go que solo pide el código de estado de una URL, cien veces seguidas, tardaba 710 milisegundos en Go 1.26. Con Go 1.27, el mismo programa, sin cambiar una sola línea, tarda unos 150 milisegundos: casi cinco veces más rápido. La diferencia no está en la red, sino en la reutilización de conexiones HTTP: si Go reaprovecha o no la conexión TCP que ya tenía abierta.

Este artículo explica qué es el keep-alive de HTTP, por qué HTTP/1.1 lo rompe tan fácil cuando el cuerpo de la respuesta queda sin leer, y cómo escribir código que aprovecha el pool de conexiones de net/http, incluso en versiones de Go anteriores a la 1.27.

TL;DR

  • Reabrir una conexión TCP y TLS en cada petición cuesta hasta 3 RTT completos antes de enviar el primer byte útil.- HTTP/1.1 mantiene la conexión abierta por defecto, pero solo si el cliente lee el cuerpo de la respuesta hasta el final.- net/http abandonaba la conexión cuando el llamador ignoraba resp.Body, forzando un handshake nuevo en cada llamada.- Go 1.27 drena en segundo plano el cuerpo sin leer antes de cerrar, para poder reciclar la conexión igual.- io.Copy(io.Discard, resp.Body) antes de Close() logra el mismo efecto en versiones anteriores de Go.

¿Qué es la reutilización de conexiones HTTP?

La reutilización de conexiones HTTP es la técnica por la cual un cliente y un servidor mantienen abierta la misma conexión TCP, y su sesión TLS si aplica, para varias peticiones seguidas en lugar de negociar una conexión nueva cada vez. Ahorra el costo de esos handshakes, que suele superar al de la petición misma.

En la práctica significa que tu cliente HTTP guarda conexiones ya establecidas en un pool organizado por host, y las reutiliza para la siguiente petición en vez de repetir todo el proceso de conexión desde cero. Cuando ese mecanismo falla sin que lo notes, el síntoma no es un error: es simplemente que todo corre más lento de lo esperado.

Por qué importa el reciclaje de conexiones TCP

Antes de que un cliente HTTP pueda enviar una sola petición sobre HTTPS pasan tres cosas en orden. Primero, el handshake TCP: el cliente envía un SYN, el servidor responde con SYN-ACK y el cliente confirma con ACK. Esto consume 1 RTT (round-trip time), porque el cliente puede mandar el primer mensaje TLS junto con ese ACK final.

Segundo, el handshake TLS: cliente y servidor intercambian claves de cifrado y el servidor prueba su identidad. Con TLS 1.2 esto toma 2 RTT adicionales; con TLS 1.3, más rápido, baja a 1 RTT. Recién después se puede mandar la primera petición HTTP, sumando hasta 3 RTT antes de recibir una sola respuesta.

Esos round-trips se notan más cuanto más lejos está el servidor. En comunicaciones entre regiones distintas, la latencia de ida suele rondar o superar los 50 ms, es decir 100 ms de RTT. Comparado con los 300 ms de montar la conexión, la petición HTTP en sí puede ser barata, apenas un poco más de 1 RTT si hay ancho de banda de sobra. El benchmark original del equipo de Go usó index.golang.org, un servicio distribuido globalmente con TLS 1.3, así que la latencia de base ya era baja. Aun así, la diferencia entre reutilizar la conexión o no fue de cinco veces.

sequenceDiagram
    participant C as Cliente
    participant S as Servidor
    C->>S: SYN
    S-->>C: SYN-ACK
    C->>S: ACK y ClientHello TLS
    Note over C,S: hasta aqui, 1 RTT de TCP
    S-->>C: ServerHello y certificado
    C->>S: claves negociadas
    Note over C,S: mas 1 o 2 RTT de TLS segun version
    C->>S: GET /index.html
    S-->>C: 200 OK

Entre regiones distintas, la latencia de ida suele superar los 50 ms por sí sola.

Cómo funciona el keep-alive de HTTP según la versión

La manera de mantener una conexión abierta cambia con cada versión del protocolo. En HTTP/1.0 el keep-alive nunca fue parte oficial de la especificación: cliente y servidor se ponían de acuerdo mandando la cabecera Connection: keep-alive, y la conexión seguía viva hasta que alguno de los dos la omitía o mandaba Connection: close.

HTTP/1.1 da vuelta esa lógica: las conexiones son persistentes por defecto. Podés enviar varias peticiones y recibir varias respuestas por el mismo socket sin negociar nada, salvo que alguno de los dos lados declare lo contrario con Connection: close.

HTTP/2 cambia el modelo entero. En lugar de mandar respuestas una detrás de otra en el mismo flujo de bytes, multiplexa varios streams de frames sobre una sola conexión TCP. Es justamente lo que evita el problema que vas a ver en la próxima sección.
VersiónCómo mantiene la conexiónQué la rompeHTTP/1.0Keep-alive opcional vía la cabecera Connection: keep-aliveOmitir la cabecera, o un Connection: close explícitoHTTP/1.1Persistente por defecto, respuestas secuenciales en el mismo socketUn cuerpo de respuesta sin leer hasta el final, o Connection: closeHTTP/2Múltiples streams multiplexados sobre una sola conexiónCasi nada a nivel de un solo stream: uno colgado no bloquea a los demás

Dónde falla el pool de conexiones TCP en HTTP/1

En HTTP/1 las respuestas viajan una detrás de otra por el mismo flujo de bytes. Si mandás dos peticiones GET por la misma conexión, las dos respuestas llegan como un único stream continuo: no hay ningún marcador que separe dónde termina la respuesta 1 y empieza la 2, aparte del propio contenido.

Por eso net/http solo puede encontrar el inicio de la siguiente respuesta después de leer la respuesta actual hasta el final, algo que determina por la cabecera Content-Length o por Transfer-Encoding: chunked. Mientras el cuerpo anterior siga sin leerse completo, no hay forma segura de saber dónde arranca lo que sigue.

Ahí está el problema con una función tan simple como esta, tomada del post oficial:

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, nil
}

Esta función nunca toca resp.Body. Hasta Go 1.26, cuando se llama a resp.Body.Close(), net/http se da cuenta de que todavía queda cuerpo sin leer en el socket. Como no puede garantizar dónde termina, directamente abandona la conexión y abre una nueva para la siguiente llamada. Si llamás a getStatus cien veces, pagás cien handshakes completos.

sequenceDiagram
    participant App as Aplicacion
    participant Lib as net/http
    participant Conn as Conexion TCP
    App->>Lib: http.Get(url)
    Lib->>Conn: GET /status
    Conn-->>Lib: linea de estado y headers
    Lib-->>App: devuelve Response de inmediato
    Note over Lib,Conn: el cuerpo de la respuesta sigue sin leerse del socket
    App->>Lib: resp.Body.Close()
    Note over Lib,Conn: Go 1.26 abandona la conexion sin saber donde termina el cuerpo

Ejemplos prácticos y cómo empezar

Vas a necesitar el toolchain de Go instalado. Cualquier versión 1.21 o superior sirve para seguir estos ejemplos, pero para ver el arreglo automático sin trucos adicionales necesitás 1.27 o superior. Si todavía no lo tenés, seguí la guía oficial de instalación para tu sistema: hay paquetes para Linux, macOS (también instalable con brew install go) y Windows (instalador .msi).

Confirmá tu versión con:

go version

La salida tiene este formato: go version go1.27.0 linux/amd64.

Ejemplo 1: la función que abandona la conexión

Guardá esto en un archivo main.go:

package main

import (
    "fmt"
    "net/http"
)

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    return resp.StatusCode, nil
}

func main() {
    status, err := getStatus("https://go.dev")
    if err != nil {
        panic(err)
    }
    fmt.Println("status:", status)
}

Corré el programa forzando HTTP/1, igual que en el benchmark original:

GODEBUG=http2client=0 go run main.go

Salida esperada:

status: 200

Si llamás a getStatus cien veces en un loop con Go 1.26, cada llamada abre una conexión nueva. En Go 1.27, el cliente intenta drenar el cuerpo sin leer antes de cerrar la conexión, así que varias llamadas terminan compartiendo el mismo socket.

Ejemplo 2: forzar la reutilización a mano

Si trabajás con una versión de Go anterior a la 1.27, podés lograr el mismo efecto leyendo el cuerpo antes de cerrarlo:

package main

import (
    "io"
    "net/http"
)

func getStatus(url string) (int, error) {
    resp, err := http.Get(url)
    if err != nil {
        return 0, err
    }
    defer resp.Body.Close()
    io.Copy(io.Discard, resp.Body)
    return resp.StatusCode, nil
}

io.Copy(io.Discard, resp.Body) lee todos los bytes que falten del cuerpo y los descarta, sin guardarlos en memoria. Eso le confirma a net/http dónde termina la respuesta, y la conexión vuelve al pool de conexiones ociosas en lugar de cerrarse.

⚠️ Ojo: si el servidor no es de confianza, no uses io.Copy(io.Discard, resp.Body) sin límite: un cuerpo enorme o que nunca termina puede colgar tu goroutine. Usá io.CopyN con un tope, o envolvé el body con http.MaxBytesReader.

Ejemplo 3: el caso del JSON a medio leer

El problema también aparece con código que sí lee el cuerpo, pero se detiene un poco antes del final. encoding/json.Decoder deja de leer en cuanto ve la llave de cierre del objeto JSON. Si el servidor manda la respuesta con un salto de línea al final, algo muy común, ese salto de línea queda sin leer:

package main

import (
    "encoding/json"
    "io"
    "net/http"
)

type statusResponse struct {
    Success bool   `json:"success"`
    Message string `json:"message"`
}

func getMessage(url string) (string, error) {
    resp, err := http.Get(url)
    if err != nil {
        return "", err
    }
    defer resp.Body.Close()

    var out statusResponse
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return "", err
    }
    io.Copy(io.Discard, resp.Body)
    return out.Message, nil
}

La línea io.Copy(io.Discard, resp.Body) después del Decode consume ese salto de línea sobrante. Sin ella, aunque tu código ya haya leído el JSON completo, la conexión queda con un byte pendiente y no se puede reciclar.

Verificación: confirmar si una conexión se reutilizó

Para comprobar esto sin adivinar, net/http/httptrace expone un callback GotConn con un campo Reused:

package main

import (
    "fmt"
    "io"
    "net/http"
    "net/http/httptrace"
)

func main() {
    client := &http.Client{}
    for i := 0; i El valor por defecto de MaxIdleConnsPerHost en net/http es de apenas 2 conexiones por host.
## Errores comunes y buenas prácticas

- **Ignorar el cuerpo porque no lo necesitás**: aunque tu lógica no use el cuerpo, igual hay que drenarlo. El costo de un `io.Copy(io.Discard, ...)` es minúsculo comparado con el de un handshake nuevo.- **Confundir Close() con ya leí todo**: cerrar el `Body` libera recursos, pero no le avisa a `net/http` que consumiste el contenido. Son dos cosas distintas.- **Decodificar JSON y no drenar el resto**: como vimos en el Ejemplo 3, `json.Decoder` puede dejar bytes sin leer aunque tu struct ya esté completo.- **No revisar el tamaño del pool de conexiones**: si tu servicio hace muchas peticiones concurrentes al mismo host, el valor por defecto de `Transport.MaxIdleConnsPerHost` (2) puede ser insuficiente y forzar handshakes de todas formas.- **Medir sin descartar el efecto de HTTP/2**: si tu cliente negocia HTTP/2 automáticamente, no vas a ver este problema porque los streams multiplexados no lo sufren. Para comparar versiones de Go de forma justa, forzá HTTP/1 con `GODEBUG=http2client=0`.

## Comparativa con alternativas

Actualizar la versión de Go no es la única manera de resolver esto. Depende de cuánto control tenés sobre tu código y sobre el servidor del otro lado.
OpciónCuándo usarlaVentajaLimitaciónActualizar a Go 1.27+Proyectos que ya controlan su versión de Go en producciónArregla el problema para todo el código existente sin tocarloNo ayuda si el servidor usa HTTP/1.0 sin keep-aliveio.Copy(io.Discard, resp.Body) manualVersiones de Go anteriores a 1.27, o librerías que no controlásFunciona en cualquier versión de GoHay que acordarse de hacerlo en cada llamadaForzar HTTP/2Cuando el servidor lo soporta y controlás ambos ladosEl problema no existe: los streams no bloquean la conexiónNo todos los servidores ni proxies intermedios lo soportan igualCliente con pool propioCargas muy altas donde el Transport por defecto no alcanzaControl fino sobre el pool de conexionesPerdés compatibilidad directa con el ecosistema estándar de net/http
## Profundizando: cómo decide net/http si reutiliza una conexión

Internamente, `net/http.Transport` mantiene un pool de conexiones ociosas organizado por host. Cuando llamás a `resp.Body.Close()`, el `Transport` decide si esa conexión vuelve al pool o se cierra para siempre.

Esa decisión depende de dos cosas: si `net/http` puede determinar con certeza dónde termina el cuerpo de la respuesta, vía `Content-Length` o el frame final de `Transfer-Encoding: chunked`, y si esos bytes ya fueron consumidos. Hasta Go 1.26, bastaba con que quedaran bytes sin leer para que la conexión se descartara de inmediato, sin intentar nada más.

Go 1.27 cambia exactamente ese punto: en lugar de abandonar la conexión en cuanto detecta cuerpo sin leer, `net/http` intenta leer y descartar esos bytes antes de decidir que no se puede reutilizar, tal como describe el [post oficial del equipo de Go](https://go.dev/blog/http-connection-reuse). Esto hace que una reutilización de conexiones HTTP exitosa ya no dependa de que la aplicación drene el cuerpo a mano.

HTTP/2 nunca tuvo este problema porque no manda las respuestas en un único stream secuencial: multiplexa frames de distintos streams sobre la misma conexión, así que un cuerpo sin leer en un stream no bloquea a los demás.

flowchart TD
A["resp.Body.Close() llamado"] --> B{"Cuerpo leido hasta el final?"}
B -- "si" --> C["Conexion vuelve al pool de idle conns"]
B -- "no, Go 1.26" --> D["Conexion se descarta y se cierra"]
B -- "no, Go 1.27" --> E["net/http drena el resto del cuerpo"]
E --> C




> **💡 Tip:** para reproducir el comportamiento exacto del benchmark original, corré tus pruebas con `GODEBUG=http2client=0`: así te asegurás de estar midiendo HTTP/1 y no un cliente que ya saltó a HTTP/2 por su cuenta.

📖 Resumen en Telegram: [Ver resumen](https://telegra.ph/Go-127-cómo-funciona-la-reutilización-de-conexiones-HTTP-10-14)

Tu próximo paso: cronometrá tu propia versión de `getStatus` en un loop de 100 llamadas, con y sin `io.Copy(io.Discard, resp.Body)`, usando `time.Since`, y compará los dos tiempos en tu propia red.

## Preguntas frecuentes

### ¿Qué diferencia hay entre el keep-alive de HTTP/1.0 y la persistencia de HTTP/1.1?

En HTTP/1.0 hay que pedirlo explícitamente con la cabecera `Connection: keep-alive`, y cualquiera de los dos lados puede cortarlo en cualquier momento. En HTTP/1.1 las conexiones son persistentes por defecto: se mantienen abiertas salvo que se declare `Connection: close`.

### ¿Por qué una reutilización de conexiones HTTP falla si no leo el cuerpo completo?

Porque en HTTP/1 las respuestas viajan una detrás de otra en el mismo flujo de bytes. Si no leíste el cuerpo actual hasta el final, `net/http` no puede saber con certeza dónde empieza la siguiente respuesta, así que prefiere cerrar la conexión en vez de arriesgarse a leer datos mezclados.

### ¿HTTP/2 sufre el mismo problema con las conexiones persistentes HTTP/1.1?

No. HTTP/2 multiplexa varios streams de frames sobre una sola conexión TCP, así que un cuerpo sin leer en un stream no bloquea la lectura de los demás. El problema descrito en este artículo es específico de HTTP/1.

### ¿Cómo verifico si Go está haciendo keep-alive de HTTP en mi programa?

Usando `net/http/httptrace` con un `ClientTrace.GotConn` que lea el campo `Reused` de `httptrace.GotConnInfo`, como se muestra en la sección de verificación más arriba.

### ¿Necesito Go 1.27 para que mi pool de conexiones TCP funcione bien en producción?

No necesariamente. Si drenás el cuerpo de la respuesta con `io.Copy(io.Discard, resp.Body)` antes de cerrarlo, el pool de conexiones ociosas de `net/http` funciona igual en versiones anteriores. Go 1.27 simplemente hace ese trabajo por vos aunque te olvides.

## Referencias

- [Go Blog: Better HTTP/1 connection reuse](https://go.dev/blog/http-connection-reuse): el post original del equipo de Go con el benchmark y la explicación del cambio en 1.27.- [pkg.go.dev: paquete net/http](https://pkg.go.dev/net/http): documentación oficial de Transport, incluido el valor por defecto de MaxIdleConnsPerHost.- [pkg.go.dev: paquete net/http/httptrace](https://pkg.go.dev/net/http/httptrace): referencia del campo Reused usado en la sección de verificación.- [MDN: Connection management in HTTP/1.x](https://developer.mozilla.org/en-US/docs/Web/HTTP/Connection_management_in_HTTP_1.x): explicación de keep-alive y conexiones persistentes en HTTP/1.- [Wikipedia: HTTP persistent connection](https://en.wikipedia.org/wiki/HTTP_persistent_connection): contexto histórico sobre el keep-alive en HTTP/1.0 y HTTP/1.1.

📱 **¿Te gusta este contenido?** Únete a nuestro canal de Telegram [@programacion](https://t.me/programacion) donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.