Node.js vs Go para una API JSON: mismo rendimiento, 2.6 veces menos memoria
Una prueba de carga lado a lado de servicios HTTP idénticos en Node.js y Go muestra dónde resulta útil la reescritura (en cuanto a memoria ocupada) y dónde no lo es (en términos de rendimiento y latencia).
“Escribáno de nuevo en Go” es una propuesta que la mayoría de los equipos de Node.js escuchan tarde o temprano; suele presentarse con afirmaciones sobre velocidad, saturación del bucle de eventos y menores costos en la nube, pero sin mediciones concretas. La forma de resolverlo es desarrollar el mismo servicio pequeño en ambos lenguajes, cargarlo de manera idéntica y observar qué diferencias persisten tras ejecuciones repetidas. Este artículo explica cómo se realizó tal experimento, qué resultados se obtuvieron realmente, cuáles de las cifras eran artificiales y cómo transformar esos resultados en una decisión sensata para la migración de sus propios servicios.
El momento en que se plantea esta pregunta no es casual. El cambio del equipo de TypeScript a un compilador nativo basado en Go ha hecho que “trasladarlo a Go” parezca la respuesta por defecto para los problemas de rendimiento (véase qué significa en la práctica la reescritura en Go de TypeScript 7). Sin embargo, un compilador es un programa por lotes limitado por la CPU; una API web tiene un perfil muy diferente, y precisamente por eso necesita sus propias mediciones.
Un servicio deliberadamente aburrido
El sujeto de prueba es una API JSON mínima que utiliza un almacenamiento de usuarios en memoria con 10,000 registros. Expose dos rutas:
GET /users/:idbusca a un usuario y lo devuelve, o bien un error JSON 404.POST /usersanaliza el cuerpo JSON, lo valida e inserta a un nuevo usuario.
No existe ninguna base de datos ni ningún framework. Ambas decisiones son intencionales: una ida y vuelta a la base de datos dominaría los tiempos de ejecución y ocultaría cualquier diferencia en el rendimiento, mientras que un framework añadiría su propio sobrecargo que no tiene nada que ver con el lenguaje. Las rutas, las reglas de validación y los datos iniciales son idénticos en ambas implementaciones.
El entorno y su asimetría inherente
Todo se ejecutó en una sola laptop Apple Silicon de 16 núcleos con macOS 26.6, Node v22.15.0 y Go 1.24.4. Node funcionó como un único proceso, sin el módulo cluster ni worker_threads, por lo que el manejo de solicitudes utilizó un solo núcleo. net/http de Go funcionó con el valor predeterminado de GOMAXPROCS, lo que permite al planificador distribuir las gorutas entre todos los núcleos.
Tenga en cuenta esa asimetría durante el resto del artículo. Es la advertencia más importante de toda esta comparación, ya que el generador de carga competía con ambos servidores por los mismos 16 núcleos.
Las dos implementaciones
La versión de Node utiliza únicamente el módulo integrado http. Observe que el enrutamiento se realiza mediante una verificación manual de req.method y una comprobación con startsWith en la URL; el almacenamiento es un simple Map, y cada respuesta se serializa con JSON.stringify y finaliza de forma explícita. La rama correspondiente a POST está resumida en un comentario; aplica las mismas verificaciones que el código en Go presentado más adelante.
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url.startsWith("/users/")) {
const id = req.url.split("/")[2];
const user = users.get(id);
if (!user) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(user));
return;
}
// POST /users: parse body, validate name + email, insert. Same checks as Go below.
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
La versión en Go registra un manejador en un ServeMux y elimina el prefijo de la ruta para obtener el ID. El almacén es un mapa protegido por un mutex, lo cual es necesario porque Go procesa las solicitudes de forma concurrente en múltiples goroutines, mientras que el bucle de eventos single-threaded de Node nunca accede al Map desde dos lugares al mismo tiempo. Las respuestas se escriben con json.NewEncoder, el cual envía los datos directamente al ResponseWriter.
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/users/")
user, ok := store.get(id)
w.Header().Set("Content-Type", "application/json")
if !ok {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "not found"})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(user)
})
La validación en la ruta POST es la misma en ambos lenguajes: name debe ser una cadena no vacía y email debe contener un @. Aquí está la función en Go; el equivalente en Node utiliza typeof y String.prototype.includes para las mismas dos verificaciones, por lo que ninguno de los lados dispone de un camino de código más eficiente.
func validate(u User) string {
if u.Name == "" {
return "name required"
}
if !strings.Contains(u.Email, "@") {
return "invalid email"
}
return ""
}
Cómo se aplicó la carga
La carga provino de autocannon, en ciclos de 15 segundos a dos niveles de concurrencia: 50 conexiones para una carga moderada y 300 conexiones para simular un endpoint con mucho tráfico.
autocannon -c 50 -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500
La ruta POST se probó de la misma manera con un cuerpo JSON, ya que decodificar y validar la entrada es más similar al trabajo real de las solicitudes que una simple búsqueda en un mapa.
La memoria se midió por separado. Se tomó una muestra del tamaño del conjunto de memoria ocupada por cada servidor (ps -o rss) en estado inactivo y nuevamente inmediatamente después de un ciclo con 300 conexiones; cada medición se realizó desde un proceso recién iniciado para que los restos de una ejecución anterior no distorsionaran los resultados. Es crucial señalar que el herramienta de medición de memoria se mantuvo desconectada de la máquina durante las pruebas de latencia; como se mostrará en las secciones siguientes, ese detalle modificó los resultados.
Antes de analizar los resultados, tenga en cuenta la situación real: se trata de una sola computadora portátil, no de un equipo de pruebas aislado, y el generador de carga comparte la CPU con los servidores. Estas cifras son específicas para esta carga de trabajo y no constituyen un juicio general sobre Node frente a Go. Al realizar una comparación como esta, guarde el código del servidor, el script de pruebas y la salida bruta (en este caso, un archivo benchmark.json) junto a sus conclusiones, y anote qué cifras se mantuvieron y cuáles se descartaron.
Rendimiento: sin ganador
Ni en una sola prueba Go logró una ventaja significativa en la tasa de solicitudes. Con 50 conexiones en la ruta GET, fue Node quien se impuso, con aproximadamente 5,000 solicitudes por segundo más. En la ruta POST y con 300 conexiones, ambos sistemas se mantuvieron a pocos cientos de solicitudes por segundo el uno del otro. La afirmación habitual de que Node “se colapsa bajo carga” no se confirmó.
La configuración explica en parte esto. Con el generador de carga y ambos servidores compartiendo 16 núcleos a través de loopback, ningún proceso sufrió escasez de CPU, lo que reduce las diferencias en el tiempo de ejecución que podrían observarse en un host de producción saturado. En una instancia dedicada pequeña, esa diferencia probablemente aumentaría. Ese es un límite real de las pruebas de rendimiento en portátiles y debería señalarse en lugar de ocultarse.
Latencia: también empate, una vez eliminado el ruido
A 300 conexiones, las latencias mediana, p99 e incluso la máxima estaban a solo unos pocos milisegundos entre sí.
Una prueba anterior había mostrado una latencia máxima de 230 ms en Node, un valor lo suficientemente significativo como para basar toda una conclusión en él. Al volver a ejecutarla en una máquina sin interferencias, sin que el muestreador de memoria compitiera por la CPU, ese pico desapareció. Provenía de las herramientas de medición, no de un problema de latencia tardía en Node.
Esta es una lección que se aplica a cualquier prueba de rendimiento en una máquina compartida. Una única cifra del caso más extremo es el valor más frágil que se obtendrá, ya que puede ser generado por un problema en el programador de tareas o por algún proceso en segundo plano. Antes de confiar en un valor máximo alarmante, vuelva a ejecutar la prueba en una máquina inactiva, observe el valor p99 junto con él y verifique si se reproduce.
Memoria: la única diferencia que persistió
La memoria residente fue la única discrepancia que era tanto grande como reproducible:
- Inactivo: Go consume 13 MB, mientras que Node consume 49 MB.
- Inmediatamente después de una carga sostenida de 300 conexiones: Go consume 32 MB, y Node, 84 MB.
La medición se repitió tres veces, cada vez con un proceso nuevo, obteniéndose resultados idénticos. Bajo esa carga, Node requiere aproximadamente 2.6 veces más memoria para realizar tareas funcionalmente idénticas.
La explicación es principalmente estructural. Un proceso Node lleva el motor V8, su compilador JIT y un montón recolectado por basura de tamaño adecuado para un lenguaje dinámico, mientras que un binario Go se compila por adelantado con un entorno de ejecución más ligero. Si pagas por límites de memoria en contenedores, la diferencia se multiplica en cada réplica: un endpoint desplegado en muchos pods paga el costo base de Node en cada uno de ellos.
Lo que este experimento no muestra
Es tentador interpretar esto como “Go supera a Node”, pero los datos no lo respaldan. El rendimiento y la latencia están relacionados, y Node ganó en las operaciones GET de baja concurrencia. Si tu restricción son solicitudes por segundo o la latencia en el extremo de una cadena de procesos en un host con núcleos disponibles, esta prueba indica que reescribir el código casi no te aporta beneficios.
Tampoco dice nada sobre el trabajo relacionado con bases de datos. La mayoría de los servicios en producción pasan la mayor parte de su tiempo de latencia esperando consultas en lugar de serializar JSON, y ese tiempo es el mismo con cualquiera de los lenguajes. Si su servicio depende mucho de operaciones de entrada/salida en Postgres, estas cifras son en gran medida irrelevantes para usted.
Finalmente, la asimetría fundamental persiste: Go utiliza cada núcleo por defecto, mientras que Node de un solo proceso utiliza uno. Algunas de las ventajas aparentes de Go en realidad se deben a que “Go se paraleliza automáticamente”. El primer paso razonable es utilizar el módulo cluster propio de Node, con un trabajador por núcleo, lo que permite a Node aprovechar el mismo hardware que Go utiliza sin costo adicional. Esto no se probó aquí, pero es mucho menos invasivo que introducir un segundo lenguaje. Tenga en cuenta que cada trabajador en el clúster es un proceso separado con su propio heap, por lo que la agrupación puede mejorar el uso de la CPU, aunque aumenta el consumo total de memoria en lugar de disminuirlo.
Conviertiendo las cifras en una decisión
Dados estos resultados, una reescritura completa no cumple con los requisitos. Una acción más dirigida sí lo hace: trasladar únicamente el endpoint que consume mucha memoria y que se ejecuta en muchas réplicas, donde la reducción a la mitad de la memoria ocupada se convierte en un factor visible, y dejar todo lo demás en Node.
Esa restricción es importante porque mover código nunca es gratuito. Un segundo lenguaje implica otro conjunto de herramientas, otro pipeline de despliegue, manuales adicionales para los ingenieros de soporte y una división en las competencias del equipo. Esas costos valen la pena cuando la memoria es el factor limitante, pero no cuando no lo es.
Cuando llegue la próxima propuesta para pasar a Go, puedes evaluarla de la misma manera:
- Desarrolla ambas versiones del servicio o endpoint en cuestión.
- Carga las mismas con el nivel de concurrencia que alcanza tu tráfico real, incluyendo la ruta de escritura.
Puntos clave
- Para una API JSON sencilla en memoria, Node y Go lograron prácticamente el mismo rendimiento y latencia en este hardware.
- La ventaja más clara y reproducible de Go fue una memoria residente un 2.6 veces menor bajo carga, lo cual es crucial para los servicios replicados a gran escala.
- La programación multihilo por defecto en Go frente al proceso único de Node es un factor confusor; pruebe el clustering antes de reescribir el código.
- Los artefactos en las pruebas de rendimiento, especialmente los picos aislados de alta latencia, son comunes en máquinas compartidas; reproduzca los resultados antes de llegar a conclusiones.
- Migre de forma selectiva, allí donde el beneficio real supera con creces el costo de aprender un segundo idioma.
Lecturas relacionadas
- Node.js Streams explicados: Cómo solucionar los fallos por falta de memoria al procesar archivos — Entienda por qué cargar archivos completos en la memoria provoca fallos en los servidores Node.js y cómo las secuencias de datos legibles, escritables, duales y transformadoras lo resuelven mediante mecanismos de contrapresión.
- One Weather CLI, cinco herramientas: Rust, Go, Zig, Bun y Node.js — Cómo se presenta la misma CLI basada en HTTP y JSON en Rust, Go, Zig, Bun y Node.js, y qué significan el tamaño del archivo binario, el tiempo de compilación y las dificultades de configuración a la hora de elegir una herramienta.
- Esperar vs. calcular: Concorrencia y paralelismo en Node.js y Go — Ejemplos ejecutables en Node.js y Go que separan la concurrencia del paralelismo, mostrando cuándo son suficientes las promesas, cuándo se necesitan hilos de trabajo y en qué difieren las goroutines.