Node.js contre Go pour une API JSON : même débit, 2,6 fois moins de mémoire
Un test de charge côte à côte de services HTTP identiques en Node.js et Go montre où une réécriture est avantageuse (mémoire résiduelle) et où elle ne l’est pas (débit et latence).
« Réécrivez-le en Go » est une proposition que la plupart des équipes Node.js entendent tôt ou tard, généralement accompagnée d’affirmations sur la vitesse, la saturation du boucle d’événements et des factures cloud plus basses, mais sans aucune mesure concrète. La façon de trancher consiste à développer le même petit service dans les deux langages, à le charger de manière identique et à voir quels différences persistent après plusieurs exécutions. Cet article décrit un tel experiment, ce qu’il a réellement montré, quels de ses chiffres étaient dus à des artefacts, et comment transformer les résultats en une décision de migration pertinente pour vos propres services.
Le moment choisi pour poser cette question n’est pas fortuit. Le passage de l’équipe TypeScript à un compilateur natif basé sur Go a fait en sorte que « le traduire en Go » semble être la réponse par défaut aux problèmes de performance (voir ce que signifie en pratique la réécriture en Go de TypeScript 7). Cependant, un compilateur est un programme par lots gourmand en ressources CPU ; une API web présente un profil très différent, c’est précisément pourquoi elle nécessite ses propres mesures.
Un service délibérément ennuyeux
L’objet d’évaluation est une API JSON minimale fonctionnant sur un stockage utilisateur en mémoire contenant 10 000 enregistrements. Elle expose deux routes :
GET /users/:idrecherche un utilisateur et le renvoie, ou une erreur JSON 404.POST /usersanalyse le corps JSON, le valide et insère un nouvel utilisateur.
Il n’y a ni base de données ni framework. Ces deux choix sont intentionnels : une requête vers la base de données affecterait fortement les temps d’exécution et masquerait toute différence de performance, tandis qu’un framework ajouterait sa propre charge inutile qui n’a rien à voir avec le langage. Les routes, les règles de validation et les données d’exemple sont identiques dans les deux implémentations.
L’environnement et son asymétrie intégrée
Tout a été exécuté sur un seul ordinateur portable Apple Silicon à 16 cœurs, équipé de macOS 26.6, Node v22.15.0 et Go 1.24.4. Node a fonctionné en tant que processus unique, sans le module cluster ni worker_threads, de sorte que le traitement des requêtes utilisait un seul cœur. Le module net/http de Go a fonctionné avec la valeur par défaut de GOMAXPROCS, ce qui permet au planificateur de répartir les goroutines sur tous les cœurs.
Gardez cette asymétrie à l’esprit pour le reste de l’article. C’est la précaution la plus importante dans toute cette comparaison, et le générateur de charge concourait avec les deux serveurs pour les mêmes 16 cœurs.
Les deux implémentations
La version Node ne fait usage que du module intégré http. Notez que le routage repose sur une vérification manuelle de req.method ainsi que sur une opération startsWith sur l’URL, que le stockage est un simple Map, et que chaque réponse est sérialisée avec JSON.stringify avant d’être terminée explicitement. La branche POST est résumée dans un commentaire ; elle applique les mêmes vérifications que le code Go présent plus loin.
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 version Go enregistre un gestionnaire sur un ServeMux et supprime le préfixe du chemin pour obtenir l’ID. Le stockage est un tableau de correspondances protégé par un mutex, ce qui est nécessaire car Go traite les requêtes en parallèle sur plusieurs goroutines, tandis que le cycle d’événements monolithique de Node ne touche jamais au Map depuis deux endroits en même temps. Les réponses sont écrites à l’aide de json.NewEncoder, qui écrit directement dans le 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 validation sur la route POST est identique dans les deux langages : name doit être une chaîne non vide et email doit contenir un @. Voici la fonction en Go ; l’équivalent Node utilise typeof et String.prototype.includes pour ces deux vérifications, de sorte qu’aucun des deux côtés ne bénéficie d’un chemin de code plus efficace.
func validate(u User) string {
if u.Name == "" {
return "name required"
}
if !strings.Contains(u.Email, "@") {
return "invalid email"
}
return ""
}
Comment la charge a été appliquée
La charge provenait de autocannon, avec des cycles de 15 secondes à deux niveaux de concurrence : 50 connexions pour une charge modérée et 300 connexions pour simuler un point de terminaison très sollicité.
autocannon -c 50 -d 15 http://localhost:PORT/users/500
autocannon -c 300 -d 15 http://localhost:PORT/users/500
La route POST a été testée de la même manière avec un corps JSON, car le décodage et la validation des données d’entrée correspondent davantage au travail réel lié aux requêtes qu’une simple recherche dans un tableau.
La mémoire a été mesurée séparément. La taille de la mémoire utilisée par chaque serveur (ps -o rss) a été enregistrée en état d’inactivité, puis immédiatement après un cycle de 300 connexions ; chaque mesure a été effectuée à partir d’un processus lancé à nouveau afin que les résidus d’une exécution précédente ne faussent pas les résultats. Il est essentiel de noter que l’outil de mesure de la mémoire a été désactivé pendant les tests de latence ; comme le montrent les sections suivantes, ce détail a modifié les résultats.
benchmark.json) à côté de vos conclusions, et notez quels chiffres ont été retenus et lesquels ont été écartés.
Aucune des tentatives n’a montré que Go prenait une avance significative en termes de taux de requêtes. Avec 50 connexions sur la route GET, c’est Node qui a pris l’avantage, d’environ 5 000 requêtes par seconde. Sur la route POST et avec 300 connexions, les deux systèmes se situaient à quelques centaines de requêtes par seconde l’un de l’autre. L’affirmation courante selon laquelle Node « se bloque sous charge » n’a pas été confirmée.
Cette configuration explique en partie ce phénomène. Avec le générateur de charge et les deux serveurs partageant 16 cœurs via un lien circulaire, aucun processus n’a manqué de ressources CPU, ce qui atténue les différences de temps d’exécution que l’on pourrait observer sur un hôte de production saturé. Sur une instance dédiée plus petite, cet écart augmenterait probablement. C’est là une limite réelle des tests de performance sur ordinateur portable, qui devrait être mentionnée plutôt que cachée.
Latence : également égale, une fois le bruit de fond éliminé
Au niveau de 300 connexions, les latences médiane, p99 et même maximale se situaient à quelques millisecondes les unes des autres.
Un essai précédent avait montré une latence maximale de 230 ms pour Node, un chiffre suffisamment marquant pour en tirer une conclusion globale. En relançant les tests sur un ordinateur silencieux, sans que le collecteur de mémoire ne concurrence la CPU, cette hausse a disparu. Elle provenait des outils de mesure et non d’un problème de latence tardive chez Node.
Ceçon est une leçon applicable à tout benchmark exécuté sur une machine partagée. Un seul chiffre correspondant au pire scénario est le plus fragile que l’on puisse obtenir, car il résulte d’un problème temporaire du planificateur ou d’un processus en arrière-plan. Avant de croire en un maximum effrayant, relancez le test sur une machine inutilisée, consultez la valeur p99 en même temps, et vérifiez si le résultat se reproduit.
Mémoire : la seule différence persistante
La mémoire résidente était le seul écart à la fois important et reproductible :
- Inactif : Go atteint 13 MB, Node atteint 49 MB.
- Immédiatement après une charge de 300 connexions continues : Go atteint 32 MB, Node atteint 84 MB.
La mesure a été répétée trois fois, à chaque fois avec un processus nouveau, et les résultats ont été identiques. Avec une charge environ 2,6 fois supérieure, Node consomme plus de mémoire pour effectuer des tâches fonctionnellement identiques.
L’explication est principalement structurelle. Un processus Node intègre le moteur V8, son compilateur JIT ainsi qu’un tas géré par collecte de déchets adapté à un langage dynamique, tandis qu’un binaire Go est compilé à l’avance avec un environnement d’exécution plus léger. Si vous payez en fonction des limites de mémoire des conteneurs, la différence s’accroît pour chaque réplique : un point de terminaison déployé sur plusieurs pods paie le coût de base de Node dans chacun d’eux.
Ce que cette expérience ne montre pas
Il est tentant d’en déduire que « Go bat Node », mais les données ne le confirment pas. La bande passante et la latence sont liées, et Node a gagné dans les opérations GET à faible concurrence. Si votre contrainte est le nombre de requêtes par seconde ou la latence finale sur un hôte disposant de cœurs supplémentaires, ce benchmark suggère qu’une réécriture ne vous apporte presque aucun avantage.
Ce texte ne dit rien non plus au sujet des tâches liées aux bases de données. La plupart des services en production passent la majeure partie de leur temps d’attente à attendre des requêtes plutôt qu’à sérialiser du JSON, et ce temps est identique quel que soit le langage utilisé. Si votre service dépend fortement des opérations I/O sur Postgres, ces chiffres ont peu d’importance pour vous.
Finalement, l’asymétrie fondamentale persiste : Go utilise chaque cœur par défaut, tandis que Node en single-process n’en utilise qu’un. Ce qui semble être un avantage de Go est en réalité le fait que « Go parallélise automatiquement ». La première solution équitable est l’utilisation du module cluster de Node, qui dispose d’un travailleur par cœur, permettant ainsi à Node d’exploiter le même matériel que Go sans effort supplémentaire. Cela n’a pas été testé ici, mais il s’agit d’une approche bien moins intrusive que l’introduction d’un deuxième langage. Il convient de se rappeler que chaque travailleur du cluster est un processus distinct avec son propre tas mémoire, de sorte que le clustering peut améliorer l’utilisation du CPU tout en augmentant la consommation mémoire totale, plutôt que de la réduire.
Transformer les chiffres en décision
Compte tenu de ces résultats, une réécriture complète ne suffit pas. Une approche plus ciblée, en revanche, le fait : il suffit de transférer uniquement l’endpoint gourmand en mémoire qui s’exécute sur de nombreuses répliques, où la réduction de moitié de la mémoire utilisée devient un élément concret, et de laisser tout le reste sur Node.
Ce choix est important car déplacer du code n’est jamais gratuit. Une deuxième langue implique un autre ensemble d’outils, un autre pipeline de déploiement, des guides supplémentaires pour les ingénieurs en service et une division des compétences au sein de l’équipe. Ces coûts sont justifiés lorsque la mémoire constitue une contrainte majeure, mais pas lorsqu’elle ne l’est pas.
Lorsque la prochaine proposition pour passer à Go arrivera, vous pourrez l’évaluer de la même manière :
- Créez les deux versions du service ou de l’endpoint en question.
- Chargez-les avec le niveau de concurrence atteint par votre trafic réel, y compris pour les opérations d’écriture.
Points clés
- Pour une API JSON simple en mémoire, Node et Go ont offert pratiquement le même débit et la même latence sur ce matériel.
- L’avantage le plus évident et reproductible de Go était une mémoire résidente environ 2,6 fois inférieure sous charge, ce qui est particulièrement important pour les services largement répliqués.
- Le planification multi-cœur par défaut de Go, contrairement à Node qui utilise un seul processus, constitue un facteur de confusion ; essayez le clustering avant de réécrire du code.
- Les artefacts des benchmarks, en particulier les pics de latence isolés, sont fréquents sur les machines partagées ; reproduisez les tests avant de tirer des conclusions.
- Migrez de manière sélective, là où les avantages mesurés sont réels et l’emportent sur le coût d’une deuxième langue.
Lectures complémentaires
- Node.js Streams Expliqués : Comment corriger les pannes dues à une mémoire insuffisante — Découvrez pourquoi charger des fichiers entiers en mémoire provoque des pannes sur les serveurs Node.js, et comment les flux lisible, écrivable, duplex et de transformation y remédient grâce à la contrainte de débit.
- One Weather CLI, cinq outils : Rust, Go, Zig, Bun et Node.js — Comment le même petit outil CLI basé sur HTTP+JSON se présente en Rust, Go, Zig, Bun et Node.js, ainsi que ce que la taille du fichier binaire, le temps de compilation et les difficultés d’installation signifient pour votre choix.
- Attente vs Calcul : Concurrentiel et Parallélisme dans Node.js et Go — Exemples exécutables en Node.js et Go qui distinguent le concurrentiel du parallélisme, montrant quand les promesses suffisent, quand des threads de travail sont nécessaires, et en quoi les goroutines diffèrent.