⏱️ Lectura: 17 min

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.

📑 En este artículo
  1. TL;DR
  2. ¿Qué es la reutilización de conexiones HTTP?
  3. Por qué importa el reciclaje de conexiones TCP
  4. Cómo funciona el keep-alive de HTTP según la versión
  5. Dónde falla el pool de conexiones TCP en HTTP/1
  6. Ejemplos prácticos y cómo empezar
    1. Ejemplo 1: la función que abandona la conexión
    2. Ejemplo 2: forzar la reutilización a mano
    3. Ejemplo 3: el caso del JSON a medio leer
    4. Verificación: confirmar si una conexión se reutilizó
  7. Casos de uso reales donde esto importa
  8. Errores comunes y buenas prácticas
  9. Comparativa con alternativas
  10. Profundizando: cómo decide net/http si reutiliza una conexión
  11. Preguntas frecuentes
    1. ¿Qué diferencia hay entre el keep-alive de HTTP/1.0 y la persistencia de HTTP/1.1?
    2. ¿Por qué una reutilización de conexiones HTTP falla si no leo el cuerpo completo?
    3. ¿HTTP/2 sufre el mismo problema con las conexiones persistentes HTTP/1.1?
    4. ¿Cómo verifico si Go está haciendo keep-alive de HTTP en mi programa?
    5. ¿Necesito Go 1.27 para que mi pool de conexiones TCP funcione bien en producción?
  12. Referencias
    1. 📚 Artículos relacionados

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. Foto de Vadim Bogulov en Unsplash

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ón Cómo mantiene la conexión Qué la rompe
HTTP/1.0 Keep-alive opcional vía la cabecera Connection: keep-alive Omitir la cabecera, o un Connection: close explícito
HTTP/1.1 Persistente por defecto, respuestas secuenciales en el mismo socket Un cuerpo de respuesta sin leer hasta el final, o Connection: close
HTTP/2 Múltiples streams multiplexados sobre una sola conexión Casi 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 < 3; i++ {
		req, _ := http.NewRequest("GET", "https://go.dev", nil)
		trace := &httptrace.ClientTrace{
			GotConn: func(info httptrace.GotConnInfo) {
				fmt.Println("conexion reutilizada:", info.Reused)
			},
		}
		req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
		resp, _ := client.Do(req)
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}
}

Salida esperada:

conexion reutilizada: false
conexion reutilizada: true
conexion reutilizada: true

La primera llamada siempre abre una conexión nueva. Si drenás el cuerpo antes de cerrar, la segunda y la tercera la reutilizan. El campo Reused está documentado en el paquete net/http/httptrace.

Casos de uso reales donde esto importa

El patrón más común es un cliente que solo le interesa el código de estado de un health check: un balanceador de carga, un sidecar de service mesh, o un script que monitorea si un endpoint interno responde. Si ese chequeo corre cada pocos segundos y nunca lee el cuerpo, cada ronda paga el costo completo de abrir conexión.

Otro caso típico son los clientes de APIs REST internas que descartan el cuerpo de respuestas de escritura, como un PUT o un DELETE que devuelve {"success": true} y nada más. El código solo necesita el status code, así que es fácil olvidarse de leer ese cuerpo pequeño pero presente.

También pasa en webhooks salientes: un servicio que notifica a decenas de suscriptores HTTP y solo verifica que cada uno respondió 200, sin importarle qué devolvió. Si esos suscriptores están en otra región, cada conexión perdida cuesta los mismos RTT de la sección anterior, multiplicados por la cantidad de notificaciones.

El valor por defecto de MaxIdleConnsPerHost en net/http es de apenas 2 conexiones por host. Foto de Miguel Ángel Padriñán Alba en Unsplash

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ón Cuándo usarla Ventaja Limitación
Actualizar a Go 1.27+ Proyectos que ya controlan su versión de Go en producción Arregla el problema para todo el código existente sin tocarlo No ayuda si el servidor usa HTTP/1.0 sin keep-alive
io.Copy(io.Discard, resp.Body) manual Versiones de Go anteriores a 1.27, o librerías que no controlás Funciona en cualquier versión de Go Hay que acordarse de hacerlo en cada llamada
Forzar HTTP/2 Cuando el servidor lo soporta y controlás ambos lados El problema no existe: los streams no bloquean la conexión No todos los servidores ni proxies intermedios lo soportan igual
Cliente con pool propio Cargas muy altas donde el Transport por defecto no alcanza Control fino sobre el pool de conexiones Perdé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. 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

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.

📬 Recibí lo nuevo en tu email

Te avisamos de artículos grandes (1-2 por mes).

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

📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @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.

Imagen destacada: Foto de Kirill Sh en Unsplash

¿Te sirvió? ¿Te dio otro error? Contalo abajo: las preguntas se responden y le sirven al siguiente que llegue.

Dejar un comentario

Andrés Morales

Desarrollador e investigador en inteligencia artificial. Escribe sobre modelos de lenguaje, frameworks, herramientas para devs y lanzamientos open source. Cubre papers de ML, ecosistema de startups tech y tendencias de programación.

0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Podés incluir código entre <code>…</code> o, para varias líneas, <pre><code>…</code></pre>.

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.