Framework Node.js fullstack — HTTP & WebSocket, même contexte
Nodefony — performance mesurée
Ce que le framework fait AUJOURD'HUI : où part le temps d'une requête, ce que coûte l'accès aux données, ce que tient un processus. Comparatif issu du jeu versionné de la 10.0.0 (mesuré le 2026-08-23) ; chaque bloc porte l'état de code où il a été pris.
Ce qu'il faut retenir
Écart avec un Express équipé du même travail
×1,09
≈ 92 % de son débit — et ×1,07 dès qu'une vraie requête SQL entre dans les deux
Ce que tient un processus
~1 650 req/s
route de lecture ; ~1 040 req/s si la session est chargée
Poids de la couche ORM du framework
< 2,5 % du CPU
le temps part dans le pilote et la base, pas dans le framework
Motifs de route exécutés par requête
2,8
sur 136 routes déclarées — le scan ne suit plus la taille de la table
Part du budget d'une requête prise par le routeur
0,6 %
~0,54 µs sur 86
Ce rapport ne revendique pas un record de débit. Il montre où part le temps d'une
requête servie par Nodefony, et ce qu'il en reste pour l'application. Le fait central est que
l'écart avec un serveur nu fond à mesure que l'application fait un travail réel : sur une
route qui rend une constante, la comparaison mesure surtout ce que chaque framework fait EN PLUS ;
dès qu'une requête SQL entre dans les deux camps, elle mesure l'application.
Lecture des chiffres absolus. Machine de développement, générateur de charge
co-localisé : les valeurs absolues sont basses pour tous les participants. Seuls les
rapports sont exploitables, à décor identique et dans la même fenêtre. Les mesures
PostgreSQL portent une réserve supplémentaire : elles sont prises derrière une virtualisation
réseau qui coûte un facteur 3,7 — aucun absolu PostgreSQL n'est transposable.
1 · L'écart avec Express, à trois niveaux d'équité
Comparer deux frameworks sur une route qui ne fait rien ne compare pas deux frameworks :
cela compare ce qu'ils font. Trois niveaux, du plus flatteur pour la
concurrence au plus honnête.
Niveau 1 — pipeline nu
node:http nu
33 821
2,5 %
×2,77
Fastify
31 960
1,3 %
×2,61
Express
17 418
1,7 %
×1,42
Nodefony
12 226
0,5 %
—
Ces chiffres viennent du jeu versionné de la
10.0.0 (mesuré le 2026-08-23,
code dfdada9e) — la même source que la page
de version, pour qu'aucune des deux ne puisse contredire l'autre.
Les rapports restent valides entre eux à la date de leur mesure.
Niveau 2 — à service égal
Le prix de ces fonctionnalités est de −19,5 % pour Express : ce n'est pas
un coût de framework, c'est le coût du travail lui-même. L'écart honnête tombe à ×1,29.
La preuve d'équité — ce que la cible Nodefony ne fait pas
Ce qui est vérifié
Comment
Résultat
Aucune session démarrée
Aucun en-tête Set-Cookie sur 1 000 réponses
0
Aucune écriture en base
PRAGMA data_version depuis une connexion ouverte pendant la fenêtre
stable
Aucune ligne ajoutée
Écarts sur les 6 tables du framework
0
Profileur non monté en production
Son plan de données répond 404
404
Chronométrage inactif
Vérifié au code : désactivé hors développement
inactif
L'instrument lui-même a été vérifié mordant : une écriture témoin par une
autre connexion fait bien bouger la valeur. Le « 0 » n'a été cru qu'après ce rouge.
Niveau 3 — à service ET ORM égaux
×1,07 face à un Express équipé du même service. Le prix des middlewares
Express sur une route ORM n'est plus que de −2,4 % (1 801 nu contre 1 758
équipé), là où il valait −19,5 % sans base : l'ORM dilue tout.
Recoupement croisé. Express passe de 1 089 à 1 801 en mémoïsant sa requête
(+65 %) ; Nodefony de 1 017 à 1 640 (+60 à 62 %). Même goulot,
même remède, deux frameworks indépendants — et la prédiction avait été engagée avant la mesure.
Mesuré le 2026-08-23 — état du code : dfdada9e — état livré 10.0.0
2 · Où part le temps d'une requête
Écouteurs Node
9,00 %
structurel
Analyse HTTP entrante
8,00 %
structurel
Pose des en-têtes sortants
7,30 %
propre au framework
Écriture sur la socket
5,00 %
structurel
Portée d'injection
4,40 %
propre au framework
Code du noyau HTTP
3,47 %
propre au framework
Fabrique de contexte
non re-profilé
propre au framework
Ramasse-miettes
1,20 %
structurel
Résolution de route
0,60 %
propre au framework
Reformatage d'URL
0,16 %
propre au framework
Nonce CSP
0,16 %
propre au framework
Armements de délai
0,05 %
propre au framework
Un profil désigne un poste, il ne le dimensionne pas. Trois fois sur ce chantier,
un pourcentage de CPU occupé a surestimé un coût réel d'un facteur 25 à 30 —
557 ns mesurés là où le profil imputait 21,6 %. La conduite qui en sort : convertir tout pourcentage
de profil en nanosecondes par un micro-banc avant d'ouvrir un chantier.
Les comptes exacts par requête (sonde, 107 618 requêtes — aucune mesure de temps)
Opération
Par requête
Détail
res.setHeader
10,0
serveur · nosniff · cadre · référent · CSP · id · traçage · type ×2 · longueur
res.removeHeader
3,0
type de contenu ×2 (aller-retour) · longueur
socket.setTimeout
3,0
2 par Node (délais désalignés) + 1 par le framework
res.writeHead (message personnalisé)
1,0
à chaque requête → chemin lent de Node
res.getHeaders() (copie intégrale)
1,0
un hasHeader maison qui copiait tout
Écouteurs attachés
4,0
fermeture ×2 · fin · terminé — majoritairement Node
res.write + res.end
2,0
deux écritures logiques
Un compte ne dépend ni de la machine, ni de la charge, ni de l'instrument. C'est lui qui a rendu les corrections évidentes.
3 · Le routeur — ce que coûte le scan quand la table grandit
136
47
0,09 µs
1,3 %
300
101
0,05 µs
3,0 %
600
201
0,07 µs
10,3 %
1 200
401
0,07 µs
36,7 %
2 400
801
0,06 µs
66,0 %
Sur la table de ce dépôt — 136 routes déclarées — 2,8 motifs
sont exécutés par requête (pire cas 11), soit ~0,54 µs sur 86 :
0,6 % du budget.
Ce qui compte ici est l'échelle, pas le débit. Le nombre de motifs exécutés ne suit
pas le nombre de routes déclarées, mais celui des routes qui partagent le préfixe demandé — une
application peut donc déclarer des centaines de routes sans que le scan devienne un poste.
Mesuré le 2026-08-07 — état du code : a42512e3
4 · L'accès aux données — le framework n'est pas le sujet
Cible de banc (contrôle)
pipeline nu
11 580
86
1,9 %
Session — magasin mémoire
session en mémoire
9 664
103
1,7 %
Session — SQLite
session via l'ORM
2 350
426
1,5 %
Écriture d'une facture
INSERT + 2 clés étrangères
1 329
752
1,4 %
Lecture allégée
find() 20 lignes
1 068
936
2,5 %
Lecture complète
+ JSON complet
1 022
978
3,0 %
Cycle utilisateur — mémoire
session mémoire + lecture
983
1 017
0,6 %
Cycle utilisateur — SQLite
session ORM + lecture
719
1 391
1,3 %
L'additivité a été vérifiée : 979 + 322 + ~90 de zone = 1 391 µs, ce que rend
effectivement la marche complète. Un escalier dont les marches ne s'additionnent pas mesure autre
chose que ce qu'il prétend.
Le profilage innocente la couche du framework
drizzle — construction de la requête
39,0 %
pilote drizzle → SQLite (préparation + exécution)
27,0 %
Node interne
5,5 %
V8 (anonyme / natif)
5,1 %
@nodefony/framework
4,8 %
repos
4,6 %
@nodefony/http
3,6 %
V8 (programme / natif)
3,1 %
cœur nodefony
2,9 %
divers
1,5 %
ramasse-miettes V8
1,1 %
@nodefony/orm-core
0,9 %
@nodefony/security + module test + adaptateur
0,8 %
La couche d'abstraction ORM de Nodefony pèse moins de 2,5 % du CPU. Ce n'est pas
une bonne nouvelle qu'on s'accorde : c'est un résultat qui ferme une piste. Optimiser
l'adaptateur n'aurait rien rendu. Le goulot est que l'ORM refabrique et re-prépare la
requête à chaque requête HTTP — 39 % de construction contre 17 % d'exécution réelle.
Mesuré le 2026-08-06 — état du code : profil pris avant la mise en cache des requêtes préparées
5 · Les requêtes préparées — ce que rend chaque moteur
Route
SQLite
PostgreSQL
Lecture allégée
2 019
1 640
Session + lecture
1 516
1 021
Ce cache ne mémorise aucune donnée — il mémorise la forme de la requête.
Les valeurs sont re-liées à chaque appel, la base est interrogée à chaque appel. Un test
anti-obsolescence garde ce contrat et a été vu rouge en le débranchant.
Moteur
Ce qui se passe réellement
D'où vient le gain
SQLite
Instruction native compilée une fois, réutilisée
compilation + JavaScript
PostgreSQL
Requête nommée ; plan mis en cache PAR CONNEXION du pool
JavaScript, surtout
MySQL
Le pilote passe par client.query() — aucune préparation protocole
JavaScript uniquement
Le gain est côté CLIENT, pas côté serveur de base. Il serait tentant de l'attribuer
au planificateur de PostgreSQL : pgbench en mode simple contre le même en mode préparé
ne rend que +3,3 %. Ce qui coûtait, c'était de refabriquer la requête à chaque appel.
Mesuré le 2026-08-07 — état du code : 1f1926a7 — décor 8121bef1
6 · Latence et blocage — une seule des deux plafonne un processus
Une base répond en 22 µs, l'autre en 1 232. C'est la première qui bloque le serveur.
Pilote
Durée de la requête
Retard du rappel armé
Verdict
SQLite (better-sqlite3)
133,00 ms
134,00 ms
bloque la boucle
PostgreSQL (pg_sleep 0,5 s)
503,00 ms
0,22 ms
ne bloque pas
La preuve tient à l'échelle de la centaine de millisecondes, donc insensible aux
erreurs d'instrument fin. Quand plusieurs mesures fines se contredisent, il faut changer
d'ordre de grandeur, pas d'instrument.
Pilote
Latence
CPU de boucle
Plafond théorique d'un processus
SQLite synchrone
22 µs
24 µs
~41 700 req/s
PostgreSQL asynchrone
1 232 µs
194 µs
~5 100 req/s
Les quatre instruments qui ont menti sur cette seule question
Instrument
Le vice
Le dégât produit
setInterval + setTimeout(0)
Node borne un délai de 0 à ~1 ms — on mesure le minuteur
« SQLite bloque 0,43 ms » pour une requête de 33 µs (facteur 13)
monitorEventLoopDelay
résolution de l'ordre de la milliseconde
rend son propre plancher pour les DEUX pilotes
Une colonne « bloque ? non »
assertion jamais mesurée, présentée comme un résultat
le plus dangereux : il ressemble à une réponse
process.cpuUsage()
compte TOUS les fils, ramasse-miettes compris
110 % du temps mural sur une réponse volumineuse
Un banc qui n'a pas mesuré doit se taire, pas répondre « non ».
Le débit est déjà à son plafond à 25 connexions. Doubler la concurrence ne rend
rien de plus — cela double la latence médiane. La tripler dégrade le débit et multiplie le
99ᵉ centile par six. Au-delà de la saturation, la concurrence ne produit pas du service : elle
produit de la file d'attente.
8 · Combien de pods ?
Route de lecture publique
~1 650 req/s / pod
Route de lecture connectée
~1 040 req/s / pod
Trois précautions valent plus que la précision du calcul : mesurer sa propre route
(les constantes valent pour ce corpus et cette base), compter la base comme une ressource
partagée (multiplier les pods multiplie les connexions), et dimensionner sur le
pic, en vérifiant que l'orchestrateur ajoute un pod plus vite que le pic ne monte.
Mesuré le 2026-08-23 — état du code : dfdada9e — état livré 10.0.0
9 · Comment lire ces chiffres — le décor ment plus souvent que le code
Aucun verdict faux de ce chantier ne venait d'une erreur de raisonnement sur le code.
Tous venaient de l'instrument ou du décor. Pire : les fenêtres les plus stables ont produit
les résultats les plus faux — un processeur bridé tient un plafond bas sans effort.
Régime CPU
Comparer une fenêtre bridée à une fenêtre libre
×1,62 à code identique
Niveau thermique
Comparer un run froid à un run chaud
peut INVERSER un verdict (−2 % vs +10 %)
Indexation système
Mesurer pendant une réindexation
11–22 % de CPU par vagues
Docker arrêté
Mesurer avec un conteneur inactif
64 % de CPU observés
Purge des résultats
Un résultat d'un autre lot dans la comparaison
—
LC_ALL=C
Une garde numérique muette (« 4,1 » vs « 4.1 »)
muette entre 3 et 4 %
Un seul serveur
Un superviseur résiduel qui tient le port
—
Pas de pause longue
La veille douce du processus inactif
−13 %, reproduit 3/3
Deux explications réfutées — dont notre propre correction
« Le round-trip réseau est incompressible » : faux, l'attente ne consomme pas la boucle.
« C'est PostgreSQL qui sature » : faux aussi — coïncidence de deux erreurs (un plan de requête à
froid lu comme un plan nominal, et 460 % de CPU lus comme une saturation alors que la machine
virtuelle dispose de 8 processeurs virtuels sur 6 cœurs physiques).
Élément mesuré pendant la charge
Valeur
Machine virtuelle, vue de l'hôte
~685 % (plafond pratique)
Proxy com.docker.backend
~152 %
Processus Node
~50 %
Enveloppe disponible
6 cœurs physiques
Facteur intérieur / extérieur
3,7 (16 222 tps dedans vs ~4 400 depuis l'hôte)
Le coupable est le chemin réseau virtualisé de Docker Desktop, pas la base.
Trois réfutations indépendantes, et aucune n'est une mesure de plus : les connexions étaient
en attente du client (40 sur 40), pgbench rendait 16 222 tps dans le
conteneur contre ~4 400 depuis l'hôte, et le « plafond » variait de 4 400 à 6 500 selon le jour.
10 · Ce qui reste ouvert
Un dossier de performance qui ne dit pas ce qu'il n'a pas mesuré demande qu'on lui fasse confiance
sur parole. La liste des trous est la seule partie vérifiable d'un dossier de mesure :
elle dit où regarder pour le prendre en défaut.
Transports HTTP et plafonds WebSocket
À re-mesurer
chiffres non rattachés à un état de code — retirés de ce rapport tant qu'ils ne le sont pas
Profil de la fabrique de contexte
Non re-profilée
allégée depuis la dernière mesure de profil : sa part actuelle n'est pas connue
Absolus PostgreSQL
Non transposables
pris derrière une virtualisation réseau à facteur 3,7
Attribution fine du chemin virtualisé
Non établie
ordre de grandeur mesuré, décomposition non
Renouvellement WebSocket
Non concluant
métrique à rampe ; non-régression établie, gain non
Mémoire par socket sécurisée
Écartée
régression sans qualité d'ajustement (R² = 0,25)
Mise à jour / insertion-remplacement ORM
Non entamé
hors du chemin chaud des bancs actuels
Index sur les clés étrangères au scaffold
Question produit
le générateur doit-il les indexer par défaut ?
MySQL — requêtes préparées
Non mesuré
gain attendu purement JavaScript (pas de préparation protocole)
Comment contester un chiffre. Tous les bancs cités sont versionnés dans
.claude/skills/nodefony-load-test/, avec leur protocole et leurs gardes. La règle interne
est explicite : quand une mesure est remise en question, c'est la mesure qu'on rejoue, pas
l'argument qu'on renforce.
Décor et rejouabilité
Élément
Valeur
Machine
MacBook Pro — Intel Core i9-8950HK @ 2,90 GHz, 6 cœurs physiques / 12 logiques, 32 Go
# Nodefony, mono-processus production, cible de banc du framework
BENCH_DUR=10 BENCH_URL=http://127.0.0.1:5151/nodefony/kernel/bench \
bash .claude/skills/nodefony-load-test/scripts/bench-ab-mono.sh <label> NF_BENCH_ROUTE=1
# Points de comparaison (mêmes routes, même charge utile)
BENCH_DUR=10 bash .claude/skills/nodefony-load-test/bench-frameworks/bench.sh fastify 5163
# Ce qu'un pilote de base coûte à la boucle d'événements
node .claude/skills/nodefony-load-test/scripts/db-backend-cost.mjs --prove
La cible /nodefony/kernel/bench n'existe que sous
NF_BENCH_ROUTE=1 : aucune surface n'est ajoutée en production par défaut.
Le dossier complet, en Markdown versionné, vit dans docs/performance/.