Cómo hice un job de Sidekiq 6 veces más rápido con HTTP keep-alive

En 2017 trabajaba en un backend Rails con Sidekiq. Éramos uno de los equipos piloto de la API de Bing Ads. Nuestra sincronización era lenta por un motivo que no esperaba: abrir conexiones.

Un job era una cuenta de cliente. Cada job hacía unas 40 peticiones HTTPS a Microsoft, la mayoría para subir archivos XML grandes.

Una sincronización completa eran 3.000 jobs. O sea, 120.000 peticiones. Varias veces al día.

Bien en pruebas, atascado en producción

Probábamos con lotes de 10 a 20 jobs. Todo funcionaba.

Luego llegó producción. Bum. Los jobs se acumulaban en la cola y una sincronización completa no terminaba nunca.

Mis sospechosos:

  • La concurrencia de Sidekiq bloqueando jobs.
  • Una fuga de memoria. Pero ninguna alerta de memoria.
  • Una petición reintentando sin parar y bloqueando a las demás.

Cronometré la generación del XML: 15 ms. Añadí HTTPLog: la llamada HTTP tardaba 44 ms. Nada lento.

El log que lo delató

No recuerdo por qué, pero activé todas las opciones de HTTPLog:

HttpLog.configure do |config|
  config.log_connect   = true
  config.log_request   = true
  config.log_data      = true
  config.log_status    = true
  config.log_response  = true
  config.log_benchmark = true
end

Y vi esto:

[2017-10-09-10:34:33.15] [httplog] Connecting: bulk.api.bingads.microsoft.com:443
[2017-10-09-10:34:33.75] [httplog] Sending: POST https://bulk.api.bingads.microsoft.com/...
[2017-10-09-10:34:33.77] [httplog] Status: 200

¿Lo ves?

600 ms entre "Connecting" y "Sending". Antes de que un solo byte de la petición saliera de nuestro servidor. Los 44 ms que había medido no los incluían.

Qué pasa en esos 600 ms

Cada petición abría una conexión nueva. Con HTTPS, eso son dos handshakes antes de hacer nada útil:

  • TCP: SYN, SYN-ACK. Un viaje de ida y vuelta.
  • TLS 1.2, lo que todo el mundo usaba en 2017: certificado, intercambio de claves. Dos viajes de ida y vuelta más.

Después, la petición en sí: otro viaje de ida y vuelta.

Lo he vuelto a medir esta semana, con un servidor a 96 ms:

Menos tiempo es mejorHandshakes TCP + TLSLa petición en sí
0100200300400msConexión nueva (TLS 1.2)tu servidorservidor APISYNSYN-ACKClientHellocertificado, claveintercambio de claves, FinishedFinishedPOSTrespuesta403 msConexión ya abiertatu servidorservidor APIPOSTrespuesta99 ms304 msantes del POST
Medido esta semana con un servidor a 96 ms. El tiempo avanza hacia abajo. Cada flecha es un trayecto por la red.

Abrir la conexión son 3 viajes de ida y vuelta. De un servidor en Europa a uno en la costa oeste de EE. UU., cada viaje tarda de 150 a 200 ms. Tres: de 450 a 600 ms. Y no controlábamos el otro lado. Era Microsoft.

Las cuentas

Teníamos 10 procesos de Sidekiq con 3 threads cada uno. 30 jobs a la vez.

Cada petición: 600 ms para abrir la conexión y unos 100 ms de trabajo real. 700 ms.

  • Un job: 40 × 0,7 s = 28 s.
  • Sincronización completa: 3.000 jobs / 30 threads = 100 jobs por thread. 100 × 28 s = 47 min.
Más corto es mejorAbrir conexionesTrabajo real
cola3.000 jobsproceso 1proceso 1, thread 1: un jobjobproceso 1, thread 2: un jobjobproceso 1, thread 3: un jobjobproceso 2proceso 2, thread 1: un jobjobproceso 2, thread 2: un jobjobproceso 2, thread 3: un jobjobproceso 3proceso 3, thread 1: un jobjobproceso 3, thread 2: un jobjobproceso 3, thread 3: un jobjobproceso 4proceso 4, thread 1: un jobjobproceso 4, thread 2: un jobjobproceso 4, thread 3: un jobjobproceso 5proceso 5, thread 1: un jobjobproceso 5, thread 2: un jobjobproceso 5, thread 3: un jobjobproceso 6proceso 6, thread 1: un jobjobproceso 6, thread 2: un jobjobproceso 6, thread 3: un jobjobproceso 7proceso 7, thread 1: un jobjobproceso 7, thread 2: un jobjobproceso 7, thread 3: un jobjobproceso 8proceso 8, thread 1: un jobjobproceso 8, thread 2: un jobjobproceso 8, thread 3: un jobjobproceso 9proceso 9, thread 1: un jobjobproceso 9, thread 2: un jobjobproceso 9, thread 3: un jobjobproceso 10proceso 10, thread 1: un jobjobproceso 10, thread 2: un jobjobproceso 10, thread 3: un jobjob30 jobs a la vez. Cada thread encadena 100 jobs.Sincronización completa, 3.000 jobs en 30 threads0 min10 min20 min30 min40 min50 minConexión por peticiónAbrir conexiones: 40 minTrabajo real: 6,7 min46,7 minConexión por jobAbrir conexiones: 1 minTrabajo real: 6,7 min7,7 min

El 86 % de ese tiempo, nuestros threads estaban esperando handshakes.

Y las pruebas no podían mostrarlo. 10 jobs en 30 threads es una sola ronda: 28 s. Lento, pero nada parecía roto.

La solución: mantener el socket abierto

Me pasé a Excon porque tiene una opción persistent:

connection = Excon.new("https://bulk.api.bingads.microsoft.com", persistent: true)

requests.each do |request|
  connection.post(path: request.path, body: request.xml) # el mismo socket cada vez
end

Una conexión por job. 40 peticiones sobre ella. Un handshake en lugar de 40.

Más corto es mejorAbrir la conexión (TCP + TLS): 600 msPetición: 100 ms
0 s5 s10 s15 s20 s25 s30 sAntes: una conexión nueva para cada peticiónPetición 1: 600 ms abriendo la conexiónPetición 1: 100 ms de trabajo realPetición 2: 600 ms abriendo la conexiónPetición 2: 100 ms de trabajo realPetición 3: 600 ms abriendo la conexiónPetición 3: 100 ms de trabajo realPetición 4: 600 ms abriendo la conexiónPetición 4: 100 ms de trabajo realPetición 5: 600 ms abriendo la conexiónPetición 5: 100 ms de trabajo realPetición 6: 600 ms abriendo la conexiónPetición 6: 100 ms de trabajo realPetición 7: 600 ms abriendo la conexiónPetición 7: 100 ms de trabajo realPetición 8: 600 ms abriendo la conexiónPetición 8: 100 ms de trabajo realPetición 9: 600 ms abriendo la conexiónPetición 9: 100 ms de trabajo realPetición 10: 600 ms abriendo la conexiónPetición 10: 100 ms de trabajo realPetición 11: 600 ms abriendo la conexiónPetición 11: 100 ms de trabajo realPetición 12: 600 ms abriendo la conexiónPetición 12: 100 ms de trabajo realPetición 13: 600 ms abriendo la conexiónPetición 13: 100 ms de trabajo realPetición 14: 600 ms abriendo la conexiónPetición 14: 100 ms de trabajo realPetición 15: 600 ms abriendo la conexiónPetición 15: 100 ms de trabajo realPetición 16: 600 ms abriendo la conexiónPetición 16: 100 ms de trabajo realPetición 17: 600 ms abriendo la conexiónPetición 17: 100 ms de trabajo realPetición 18: 600 ms abriendo la conexiónPetición 18: 100 ms de trabajo realPetición 19: 600 ms abriendo la conexiónPetición 19: 100 ms de trabajo realPetición 20: 600 ms abriendo la conexiónPetición 20: 100 ms de trabajo realPetición 21: 600 ms abriendo la conexiónPetición 21: 100 ms de trabajo realPetición 22: 600 ms abriendo la conexiónPetición 22: 100 ms de trabajo realPetición 23: 600 ms abriendo la conexiónPetición 23: 100 ms de trabajo realPetición 24: 600 ms abriendo la conexiónPetición 24: 100 ms de trabajo realPetición 25: 600 ms abriendo la conexiónPetición 25: 100 ms de trabajo realPetición 26: 600 ms abriendo la conexiónPetición 26: 100 ms de trabajo realPetición 27: 600 ms abriendo la conexiónPetición 27: 100 ms de trabajo realPetición 28: 600 ms abriendo la conexiónPetición 28: 100 ms de trabajo realPetición 29: 600 ms abriendo la conexiónPetición 29: 100 ms de trabajo realPetición 30: 600 ms abriendo la conexiónPetición 30: 100 ms de trabajo realPetición 31: 600 ms abriendo la conexiónPetición 31: 100 ms de trabajo realPetición 32: 600 ms abriendo la conexiónPetición 32: 100 ms de trabajo realPetición 33: 600 ms abriendo la conexiónPetición 33: 100 ms de trabajo realPetición 34: 600 ms abriendo la conexiónPetición 34: 100 ms de trabajo realPetición 35: 600 ms abriendo la conexiónPetición 35: 100 ms de trabajo realPetición 36: 600 ms abriendo la conexiónPetición 36: 100 ms de trabajo realPetición 37: 600 ms abriendo la conexiónPetición 37: 100 ms de trabajo realPetición 38: 600 ms abriendo la conexiónPetición 38: 100 ms de trabajo realPetición 39: 600 ms abriendo la conexiónPetición 39: 100 ms de trabajo realPetición 40: 600 ms abriendo la conexiónPetición 40: 100 ms de trabajo real28 sDespués: una conexión por job600 ms abriendo la conexión, una sola vez40 peticiones × 100 ms de trabajo real4,6 s
Un job, 40 peticiones, a escala. Pasa el ratón sobre un bloque para ver su duración.
  • Un job: 0,6 s + 40 × 0,1 s = 4,6 s en lugar de 28 s.
  • Sincronización completa: 100 × 4,6 s = 7,7 min en lugar de 47.

6 veces más rápido. De 120.000 handshakes por sincronización a 3.000.

Y también hay que contar la ventaja del slow start en los XML grandes

Esas subidas de XML grandes tenían un segundo problema. Una conexión TCP nueva empieza despacio.

El emisor no conoce tu ancho de banda, así que empieza con unos 14 KB en vuelo y los duplica en cada viaje de ida y vuelta: 14, 28, 57, 114 KB... Una conexión nueva necesita varios viajes para mover un archivo grande. Una conexión caliente ya ha cogido velocidad.

Aquí tienes 1 MB llegando desde Singapur, viaje a viaje:

Más KB por viaje es mejorConexión nueva: 5 viajesConexión caliente: 1 viaje
KB02505007501.000Viaje 1, conexión nueva: 82 KB82Viaje 1, conexión caliente: 1.000 KB1.000viaje 1Viaje 2, conexión nueva: 82 KB82viaje 2Viaje 3, conexión nueva: 147 KB147viaje 3Viaje 4, conexión nueva: 262 KB262viaje 4Viaje 5, conexión nueva: 427 KB427viaje 5
1 MB desde Singapur, 290 ms por viaje de ida y vuelta, medido el 30 de septiembre de 2026. KB recibidos en cada viaje después del primer byte. Cada viaje extra cuesta otros 290 ms.

Y el mismo 1 MB desde distintas distancias:

Más corto es mejorConexión nuevaConexión caliente
05001.0001.5002.0002.500a 4 msa 4 ms: conexión nueva: 57 ms57 msa 4 ms: conexión caliente: 43 ms43 msa 49 ms (Alemania)a 49 ms (Alemania): conexión nueva: 329 ms329 msa 49 ms (Alemania): conexión caliente: 147 ms147 ms · 2,2× más rápidoa 106 ms (costa este EE. UU.)a 106 ms (costa este EE. UU.): conexión nueva: 734 ms734 msa 106 ms (costa este EE. UU.): conexión caliente: 114 ms114 ms · 6,5× más rápidoa 290 ms (Singapur)a 290 ms (Singapur): conexión nueva: 2.027 ms2.027 msa 290 ms (Singapur): conexión caliente: 297 ms297 ms · 6,8× más rápido
Descarga de 1 MB después del handshake, medida el 30 de septiembre de 2026. Mismo ancho de banda. La conexión nueva necesita más viajes para coger velocidad.

Reutilizar la conexión también arregló esto. Ni siquiera lo sabía en ese momento.

Un detalle en Linux: la ventana vuelve a encogerse cuando la conexión se queda inactiva. En los servidores de Sidekiq, net.ipv4.tcp_slow_start_after_idle=0 la mantiene caliente.

¿Sigue siendo cierto en 2026?

Escribí un pequeño script en Python y medí 11 APIs de LLM (OpenAI, Anthropic, OpenRouter, Gemini, Mistral, Groq y más), además de algunos servidores lejanos. 20 peticiones seguidas: una conexión nueva cada vez, y luego una sola conexión.

Más corto es mejorConexión nueva en cada peticiónUna sola conexión para las 20
0 s2,5 s5 s7,5 s10 s12,5 sServidor a 107 ms, TLS 1.2Servidor a 107 ms, TLS 1.2: conexión nueva cada vez: 10,8 s10,8 sServidor a 107 ms, TLS 1.2: una sola conexión: 2,6 s2,6 s · 4,2× más rápidoServidor a 162 ms, TLS 1.3Servidor a 162 ms, TLS 1.3: conexión nueva cada vez: 12,4 s12,4 sServidor a 162 ms, TLS 1.3: una sola conexión: 5,8 s5,8 s · 2,1× más rápidoOpenRouterOpenRouter: conexión nueva cada vez: 0,9 s0,9 sOpenRouter: una sola conexión: 0,3 s0,3 s · 3,2× más rápidoFireworksFireworks: conexión nueva cada vez: 1,8 s1,8 sFireworks: una sola conexión: 0,6 s0,6 s · 3,1× más rápidoAnthropicAnthropic: conexión nueva cada vez: 2,9 s2,9 sAnthropic: una sola conexión: 2,5 s2,5 s · 1,1× más rápidoOpenAIOpenAI: conexión nueva cada vez: 4,0 s4,0 sOpenAI: una sola conexión: 3,7 s3,7 s · 1,1× más rápido
20 peticiones seguidas, medidas el 30 de septiembre de 2026 desde mi Mac, por HTTPS. Las llamadas a las APIs van sin clave: es tiempo de red, no tiempo del modelo.

Desde 2017 han cambiado dos cosas. Las APIs están detrás de un CDN a pocos ms de ti, y TLS 1.3 necesita un solo viaje de ida y vuelta en lugar de dos. Así que abrir una conexión con OpenAI o Anthropic cuesta unos 20 ms desde mi escritorio.

Cuando el servidor tarda más de 100 ms en responder, eso es entre el 7 % y el 26 % de cada petición. Cuando es rápido, el handshake es la mayor parte: OpenRouter pasa de 45 ms a 14 ms por petición.

¿Y un servidor lejano que sigue con TLS 1.2? 4,2× más rápido con una sola conexión. La misma historia que en 2017.

Lo que hago ahora

  • Un cliente HTTP por thread, reutilizado. En Ruby: Net::HTTP.start con un bloque, net-http-persistent, HTTPX, o Excon con persistent: true. En Python: requests.Session o httpx.Client.
  • Crea el cliente de OpenAI o Anthropic una sola vez. Guarda el pool de conexiones. Un cliente nuevo por petición lo tira a la basura.
  • Registra el tiempo de conexión, no solo el de la petición. Ahí se escondían mis 600 ms.
  • Prueba con volumen de producción. Con 10 jobs nunca lo verás.