
En una auditoría me encontré con un patrón que parece inofensivo hasta que miras la cascada de red: dos imágenes pesadas, de **608 KB** y **403 KB**, servidas bajo **HTTP/1.1**, con la cabecera `Connection: Keep-Alive` presente y **ausencia total de `Cache-Control`**. A primera vista alguien podría pensar que el `Keep-Alive` ya está "cacheando" algo. No cachea nada. Son dos capas distintas que resuelven problemas distintos, y confundirlas se paga en cada visita.

Vamos a ver qué hace realmente cada cabecera, por qué `Keep-Alive` ayuda pero no basta en HTTP/1.1, cómo HTTP/2 cambia las reglas con la multiplexación, y dónde encaja `<link rel="preconnect">` para adelantarse al handshake. Al final hay una demo interactiva donde puedes recorrer los cuatro escenarios y ver qué cambia, y qué no, en cada uno de ellos.

## El hallazgo en la auditoría

Las dos respuestas compartían las mismas cabeceras; la de la imagen más pesada tenía esta pinta:

```http
❌ Bad: respuesta real auditada
HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 622592
Connection: Keep-Alive
```

Dos cosas saltan a la vista:

1. **Hay `Connection: Keep-Alive`**, así que la conexión se reutiliza. Útil, pero eso es la conexión; no guarda ni un byte para la próxima visita.
2. **No hay `Cache-Control` por ningún lado.** Esto significa que quien navega vuelve a descargar más de **1 MB** de imágenes en **cada visita**, e incluso al navegar entre páginas si el navegador decide revalidar. Un megabyte que podría vivir en caché durante meses se descarga una y otra vez.

La cabecera que de verdad importa para las visitas recurrentes brillaba por su ausencia:

```http
✅ Good: lo que debería servir el origen
HTTP/2 200
content-type: image/jpeg
content-length: 622592
cache-control: public, max-age=31536000, immutable
```

El error mental detrás de esto suele ser pensar que `Keep-Alive` y `Cache-Control` van de lo mismo. No. Operan en capas diferentes.

## Capa de red vs capa de almacenamiento

La distinción es esta:

- **`Connection: Keep-Alive` gestiona el _tubo_.** Vive en la capa de red. Decide si la conexión TCP que ya hemos abierto se mantiene viva para reutilizarla en la siguiente petición, en lugar de cerrarla y volver a hacer el handshake. Su efecto se mide **dentro de una misma carga de página**.
- **`Cache-Control` gestiona los _datos_.** Vive en la capa de almacenamiento del cliente. Decide si la respuesta se guarda en disco y durante cuánto tiempo, para no tener que volver a pedirla. Su efecto se mide **entre cargas, entre páginas y entre visitas**.

Una analogía: `Keep-Alive` es mantener abierta la tubería del agua para no tener que volver a conectar el grifo a la red en cada vaso. `Cache-Control` es llenar una jarra y dejarla en la nevera para no abrir el grifo siquiera. Son optimizaciones complementarias, no sustitutas.

|               | `Connection: Keep-Alive`  | `Cache-Control`                    |
| ------------- | ------------------------- | ---------------------------------- |
| Capa          | Red (conexión TCP)        | Almacenamiento (caché del cliente) |
| Qué reutiliza | El tubo abierto           | Los bytes ya descargados           |
| Ámbito        | Dentro de una carga       | Entre cargas y visitas             |
| Si falta      | Re-handshake por petición | Re-descarga del recurso completo   |

En el caso auditado teníamos lo primero pero no lo segundo. Reutilizábamos el tubo, pero tirábamos los datos a la basura después de cada visita. El arreglo más barato y de mayor impacto era añadir `Cache-Control` con un `max-age` largo e `immutable` para assets versionados.

## El impacto de Keep-Alive en HTTP/1.1

Dejando la caché a un lado, miremos qué hace `Keep-Alive` durante esa primera carga, porque aquí hay un matiz que mucha gente da por hecho y es falso.

El problema de fondo de HTTP/1.1 es que **una conexión solo procesa una petición a la vez**: no hay multiplexación. Para descargar las dos imágenes a la vez, el navegador **abre dos conexiones en paralelo** hacia el dominio de assets (el límite son unas 6 por origen). Cada conexión paga su propio peaje: handshake TCP (SYN, SYN-ACK, ACK) y, sobre HTTPS, también el handshake TLS. Son dos handshakes, en paralelo, antes de poder descargar.

Aquí está el matiz: con dos imágenes concurrentes, **`Connection: close` y `Connection: keep-alive` tardan lo mismo en esta primera carga**. En ambos casos el navegador abre dos conexiones nuevas y negocia dos handshakes; no hay ninguna conexión abierta previa hacia assets que reutilizar. La diferencia es el ciclo de vida: con `close` las conexiones se cierran al terminar; con `keep-alive` se quedan abiertas en el pool.

¿Dónde se nota entonces el `keep-alive`? En la **siguiente** petición a ese mismo origen: otra imagen al hacer scroll, una navegación, una llamada a la API. Esa petición reutiliza una conexión abierta y se ahorra el handshake. Es exactamente el mismo malentendido que con la caché: igual que `Keep-Alive` no cachea nada, **tampoco acelera la primera carga**, solo las posteriores.

Resumiendo el comportamiento de HTTP/1.1:

- Sin multiplexación: una conexión, una petición a la vez. Concurrencia limitada a unas 6 conexiones por origen.
- `Connection: close`: la conexión se cierra tras cada respuesta. Cero reutilización.
- `Connection: keep-alive`: mismo coste en la primera carga, pero la conexión queda abierta para reutilizarla después.

## La evolución hacia HTTP/2 y el superpoder de preconnect

Con HTTP/2 esto cambia. Sobre **una única conexión** introduce **multiplexación**: cada recurso viaja como un _stream_ independiente, y muchos streams comparten la misma conexión al mismo tiempo. Las dos imágenes ya no necesitan dos conexiones con dos handshakes: viajan por la misma, en paralelo, y desaparece el límite de 6 conexiones por origen.

Esto tiene una consecuencia directa: **`Connection: Keep-Alive` deja de tener sentido en HTTP/2**. La cabecera ni siquiera es válida en el protocolo; la conexión persistente y multiplexada es el comportamiento por defecto. Si ves `Keep-Alive` en una respuesta, es una pista de que sigues en HTTP/1.1.

Ahora bien, incluso en HTTP/2 queda un coste que la multiplexación no elimina: **el handshake inicial hacia el dominio de assets**. El navegador no descubre que necesita ese dominio hasta que parsea el HTML y encuentra las imágenes. Solo entonces empieza a resolver DNS, abrir TCP y negociar TLS. Esos _round trips_ se cuelan en la ruta crítica, justo antes de las descargas.

Aquí entra `<link rel="preconnect">`. Es un _resource hint_ que le dice al navegador: "vas a necesitar este origen, ve abriendo la conexión ya".

```html
<link rel="preconnect" href="https://assets.example.com" crossorigin />
```

Colocado en el `<head>`, el navegador ejecuta el handshake TCP/TLS hacia `assets.example.com` **en paralelo con la descarga y el parseo del HTML**. Cuando llega el momento de pedir las imágenes, la conexión ya está abierta: el handshake desaparece de la ruta crítica y las descargas empiezan al instante.

Un par de matices que comprobé en la auditoría:

- El atributo `crossorigin` importa. Úsalo cuando el recurso se pide en modo CORS (fuentes, o imágenes con `crossorigin`). Si el `crossorigin` del `preconnect` no coincide con cómo se solicita el recurso, el navegador abre **dos conexiones** y desperdicias el hint.
- `preconnect` cuesta recursos (mantiene un socket abierto). Resérvalo para los **2 o 3 orígenes críticos** de verdad. Para el resto, `dns-prefetch` es una alternativa más barata que solo resuelve el DNS.

### Visualízalo: la cascada en cuatro estados

Cambia entre los cuatro escenarios y compara cuántas conexiones TCP se abren, cuántos handshakes hay en la ruta crítica y el tiempo total de cada caso. Fíjate especialmente en dónde aparece, o desaparece, la barra roja de handshake.

<figure>
  <iframe
    id="netwf-demo"
    src="/demos/network-waterfall.html"
    width="100%"
    height="720"
    style="border: none; border-radius: 8px; display: block;"
    title="Cascada de red interactiva que compara HTTP/1.1 con Connection close, HTTP/1.1 con keep-alive, HTTP/2 y HTTP/2 con preconnect para la carga de dos imágenes de 608 KB y 403 KB"
    loading="lazy"
  ></iframe>
  <figcaption>Modelo realista: documento en el origen principal e imágenes en un dominio de assets. Los tiempos son estimaciones didácticas con bloques fijos (handshake 100 ms, descarga proporcional al peso: 608 KB ≈ 220 ms, 403 KB ≈ 150 ms) para aislar el efecto de cada optimización.</figcaption>
</figure>
<script>
window.addEventListener('message', function(ev) {
  if (ev.data && typeof ev.data.netWaterfallHeight === 'number') {
    var f = document.getElementById('netwf-demo');
    if (f) f.style.height = (ev.data.netWaterfallHeight + 24) + 'px';
  }
});
</script>

Lo interesante es lo que _no_ cambia. Para dos imágenes, `close`, `keep-alive` y HTTP/2 tardan prácticamente lo mismo en la primera carga (unos **570 ms** en el modelo): el cambio de protocolo reduce **conexiones** (de 3 a 2) y **handshakes en la ruta crítica** (de 2 a 1), pero no recorta el tiempo total. El único que lo recorta es `preconnect`, que adelanta el handshake y baja a **470 ms**. La multiplexación de HTTP/2 brilla de verdad cuando hay decenas de recursos, donde el techo de 6 conexiones de HTTP/1.1 empieza a serializar peticiones. Y ojo, todo esto es solo la _primera_ visita; con un buen `Cache-Control`, la segunda ni siquiera toca la red para estas imágenes.

## Conclusión y buenas prácticas

Volviendo al hallazgo original, estas eran las correcciones que aplicaban a ese caso real:

- **Añadir `Cache-Control` a las imágenes.** Es el cambio de mayor impacto y el que faltaba. Para assets con hash en el nombre: `cache-control: public, max-age=31536000, immutable`. Esto ataca la capa que `Keep-Alive` nunca tocó: las visitas recurrentes.
- **Migrar el origen a HTTP/2** (o HTTP/3). La multiplexación elimina el Head-of-Line blocking de aplicación y vuelve irrelevante el `Keep-Alive`. Si sigues viendo esa cabecera, sigues en HTTP/1.1.
- **Añadir `preconnect` al dominio de assets**, con el `crossorigin` correcto, para adelantar el handshake TCP/TLS. Limítalo a los orígenes críticos.
- **Y lo más obvio: optimizar las imágenes.** 608 KB y 403 KB son demasiado para dos imágenes. Formatos modernos<sup>1</sup> (AVIF, WebP) y dimensionado correcto reducen esos bytes antes de que la red entre en juego. La mejor petición es la que no se hace, y los mejores bytes son los que no se envían.

Cada vez veremos menos `Keep-Alive`: con HTTP/2 y HTTP/3 como norma, la cabecera ni siquiera aplica. Pero todavía nos lo encontramos en aplicaciones antiguas y en infraestructuras de países con limitaciones de recursos, así que conviene saber leerlo cuando aparece en una auditoría.

`Keep-Alive` y `Cache-Control` no compiten: trabajan en capas distintas. Cuando entiendes que una gestiona el tubo y la otra los datos, dejas de confundir una conexión reutilizada con un recurso cacheado, y empiezas a optimizar las dos cosas a la vez.

## Notas

> 1. Lo defino como "formatos modernos" por consistencia con la información que nos ofrece Lighthouse en sus tests, pero el formato **WebP** se publicó el [30 de septiembre de 2010](https://es.wikipedia.org/wiki/WebP) y **AVIF** el [19 de febrero de 2019](https://es.wikipedia.org/wiki/AVIF). Para mí, un formato moderno es [JPEG-XL](https://jpegxl.info/), del cual quiero escribir un post para analizarlo en profundidad.
