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:
❌ 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:
- 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. - No hay
Cache-Controlpor 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:
✅ 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-Alivegestiona 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-Controlgestiona 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”.
<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
crossoriginimporta. Úsalo cuando el recurso se pide en modo CORS (fuentes, o imágenes concrossorigin). Si elcrossorigindelpreconnectno coincide con cómo se solicita el recurso, el navegador abre dos conexiones y desperdicias el hint. preconnectcuesta recursos (mantiene un socket abierto). Resérvalo para los 2 o 3 orígenes críticos de verdad. Para el resto,dns-prefetches 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.
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-Controla 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 queKeep-Alivenunca 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
preconnectal dominio de assets, con elcrossorigincorrecto, 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 modernos1 (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
- 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 y AVIF el 19 de febrero de 2019. Para mí, un formato moderno es JPEG-XL, del cual quiero escribir un post para analizarlo en profundidad.