Goroutines vs Event Loop: la comparación equivocada entre dos modelos de concurrencia

Node.js con Event Loop y Go con goroutines no resuelven el mismo problema del mismo modo. El error común es confundir concurrencia con paralelismo.

La tesis es simple: Node.js con Event Loop y Go con goroutines no resuelven el mismo tipo de problema del mismo modo. La comparación se vuelve mala cuando tratamos ambos como competidores directos en cualquier escenario. En la práctica, el error más común es confundir concurrencia con paralelismo.

Concurrencia es organizar varias tareas que pueden estar en curso al mismo tiempo. Paralelismo es ejecutar trabajo de hecho al mismo tiempo, usando múltiples núcleos de CPU. Esa diferencia parece académica hasta que aparece en producción.

El Event Loop de Node.js es muy bueno cuando el cuello de botella está en la espera: API externa, base de datos, WebSocket, input de usuario, colas y eventos. Mientras una operación aguarda respuesta, el loop sigue atendiendo otras tareas. Es el dueño de la bodega en el mostrador: no para porque pidió a alguien buscar rapadura en el depósito.

Las goroutines, por otro lado, empiezan a volverse más interesantes cuando el trabajo es CPU bound, divisible y puede aprovechar múltiples núcleos con control explícito de concurrencia. Son unidades ligeras de ejecución gestionadas por el runtime de Go. Con ellas, se puede romper una tarea en partes menores, distribuir la ejecución y sincronizar el resultado al final.

Es más parecido a un puesto lleno en el São João de Caruaru: una persona asa el maíz, otra revuelve la canjica, otra corta el bolo de rolo. El trabajo avanza al mismo tiempo, con cada persona cuidando una parte.

El punto donde Node.js empieza a sufrir

Lo vi de forma muy concreta en un proceso de cálculo de comisión en una casa de apuestas. La aplicación lidiaba con millones de apuestas por día, y parte del flujo implicaba calcular comisión sobre varios lotes de apuestas.

Al principio, el proceso en Node.js funcionaba. Secuencialmente era correcto, pero lento. Cuando intenté paralelizar con la lógica común de varias tareas al mismo tiempo, apareció el límite: el cuello de botella era CPU.

No era solo esperar base, API o evento externo. Era cálculo sobre un buffer grande de apuestas.

Ese es el tipo de escenario en que Promise.all puede engañar. Da sensación de paralelismo, pero no transforma automáticamente trabajo pesado de CPU en ejecución paralela real. Si las tareas son cómputo intenso y corren en el mismo hilo principal, el Event Loop queda ocupado.

El resultado puede ser peor de lo esperado: bloqueo del loop, aumento de latencia, peor responsividad y mayor presión sobre CPU y memoria.

El problema no era que Node.js fuera malo. El problema era usar el modelo estándar de Node para una carga que exigía otro tipo de ejecución.

Donde Go encajó mejor

La solución fue reescribir ese proceso en Go usando goroutines. La idea era dividir el cálculo en chunks menores, procesar esos pedazos en paralelo y sincronizar solo al final.

Ese diseño encajaba mejor en el problema porque el trabajo era CPU bound y podía dividirse. En lugar de un flujo centralizado intentando coordinar varias operaciones pesadas, el procesamiento pasó a distribuirse en unidades menores de ejecución.

Con un worker pool, por ejemplo, se puede controlar el número de goroutines, limitar el fan-out, usar mejor los cores disponibles y evitar que el sistema dispare trabajo sin límite.

La ganancia apareció. El tiempo total cayó cerca del 25%. El proceso que quedaba en torno a los 30 segundos pasó a correr en algo cercano a 22,5 segundos. También hubo mejor uso de CPU.

Pero la parte importante de la historia no es “Go lo resolvió”. La parte importante es que Go resolvió un lado del problema y reveló otro.

El cuello de botella puede cambiar de lugar

La primera dificultad fue garantizar que el resultado en Go fuera igual al resultado en Node.js. Esto es menos glamoroso que hablar de concurrencia, pero es lo que separa la optimización real de la regresión enmascarada.

Si el cálculo se vuelve más rápido y cambia el resultado financiero, la mejora no vale nada.

Después de eso, el problema principal se volvió la división de los chunks. La estrategia inicial consumía demasiada memoria. En pruebas locales, con datasets menores, el crecimiento proporcional llegó cerca del 15% en algunos momentos.

La incomodidad venía de una expectativa equivocada: pensé que cambiar a Go resolvería automáticamente el problema. En la práctica, solo había movido el cuello de botella. Antes el límite estaba más claro en CPU. Después, la estrategia de particionamiento empezó a presionar la memoria.

Esto puede pasar por varios motivos: copias innecesarias, buffers grandes, slices manteniendo referencia a arrays mayores, colas internas demasiado grandes o exceso de trabajo preparado antes de procesarse.

En el resultado final, la memoria aún creció cerca del 5%. En ese caso, la ganancia de tiempo y CPU compensó la pérdida. Pero esto no es una regla universal. Si la carga de producción fuera mucho mayor, o si el servicio corriera con poco margen de memoria, ese intercambio podría dejar de ser aceptable.

El paralelismo cuesta coordinación, asignación, sincronización y observabilidad. No existe ejecución paralela gratis.

Cuándo mantendría Node.js

Mantendría Node.js sin incomodidad para orquestación de I/O: comunicación por WebSocket, llamadas a varias APIs, consultas en base, disparo de eventos, integración entre servicios y flujos donde el tiempo muerto está en la espera.

En esos casos, el Event Loop es una excelente elección. Permite alto volumen de operaciones concurrentes sin crear un hilo por request. Para aplicaciones orientadas a eventos, esto es simple, productivo y fácil de encajar en el ecosistema JavaScript.

El error es intentar empujar ese mismo modelo hacia un cálculo pesado y pensar que la concurrencia de I/O se vuelve paralelismo de CPU. No se vuelve.

Cuándo miraría hacia Go

Empezaría a mirar hacia Go cuando la tarea fuera claramente CPU bound: cálculo en alto volumen de datos, procesamiento de imagen en lote, agregaciones pesadas, compresión, transformación grande de buffers, simulaciones o cualquier rutina en que la máquina pase más tiempo calculando que esperando respuesta externa.

En ese tipo de escenario, las goroutines con worker pool dan mejor control sobre el uso de CPU. También dejan más explícita la separación entre unidades de trabajo, sincronización y recolección de resultado.

Pero Go también cobra precio. Hay que pensar en granularidad de los chunks, consumo de memoria, cancelación, tratamiento de error, backpressure, límites de workers, contención y consistencia del resultado.

Si la división del trabajo es ingenua, la ganancia de CPU puede venir acompañada de estallido de memoria o complejidad innecesaria.

Contraargumento: Node.js también tiene worker threads

Existe un contraargumento justo: Node.js no está limitado al Event Loop para todo. Los worker threads existen justamente para ejecutar trabajo pesado fuera del hilo principal. También hay estrategias con colas, procesos separados, servicios auxiliares y native addons.

Entonces la comparación honesta no es “Node.js no puede”. Puede.

La cuestión es costo de implementación, madurez del equipo, observabilidad, integración con el sistema existente y cuánto esfuerzo vale invertir para mantener ese procesamiento dentro del ecosistema Node.

En algunos equipos, usar worker threads puede bastar y ser más barato que introducir Go. En otros, separar el procesamiento CPU bound en un servicio Go puede ser más simple de operar y escalar.

La decisión no debería nacer de preferencia por lenguaje. Debería nacer de la naturaleza de la carga.

La regla práctica que quedó

Después de ese caso, la regla que me quedó es esta: si el problema es esperar muchas cosas al mismo tiempo, Node.js con Event Loop tiende a orquestar muy bien. Si el problema es calcular muchas cosas al mismo tiempo, considero Go con goroutines más temprano.

Pero también pasé a desconfiar de la migración que promete resolverlo todo. Cambiar tecnología puede solo desplazar el cuello de botella.

En mi caso, mejoró CPU y tiempo, pero obligó a reanalizar memoria y chunking.

Al final, la pelea entre goroutines y Event Loop es menos sobre qué modelo es superior y más sobre qué cuello de botella intentas atacar.

Para I/O, el mostrador de la bodega funciona muy bien. Para CPU, a veces necesitas más gente en la cocina de la fiesta.


Publicado originalmente en LinkedIn el 24 de junio de 2026.