Nodefony
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.

× L'application ne fait rien Même travail par requête Même travail ET une vraie requête SQL 0,0 0,3 0,6 0,9 1,2 1,5 1,8 1,42 1,09 1,07 L'écart fond quand l'application grandit 1,42 L'application ne fait rien · 1,09 Même travail par requête · 1,07 Même travail ET une vraie requête SQL
× L'application ne fait rien Même travail par requête Même travail ET une vraie requête SQL 0,0 0,3 0,6 0,9 1,2 1,5 1,8 1,42 1,09 1,07 L'écart fond quand l'application grandit 1,42 L'application ne fait rien · 1,09 Même travail par requête · 1,07 Même travail ET une vraie requête SQL
req/s node:http nu Fastify Express Nodefony 0 10 000 20 000 30 000 40 000 33 821 31 960 17 418 12 226 Débit sur une route qui rend un objet constant
req/s node:http nu Fastify Express Nodefony 0 10 000 20 000 30 000 40 000 33 821 31 960 17 418 12 226 Débit sur une route qui rend un objet constant
node:http nu33 8212,5 %×2,77
Fastify31 9601,3 %×2,61
Express17 4181,7 %×1,42
Nodefony12 2260,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.

Mesuré le 2026-08-23 — état du code : dfdada9e — état livré 10.0.0

2 · Où part le temps d'une requête

Étages traversés par une requête HTTPAnalyse HTTP entrante : Node — 8 % · Écouteurs Node : Node — 9 %, 94 % des attaches · Portée d'injection : ~2 µs réels (sonde) · Fabrique de contexte : allégée — non re-profilée depuis · Analyse d'URL : une seule analyse par requête · Résolution de route : 2,8 motifs — 0,6 % du budget · Pare-feu (zones, CSRF, en-têtes) : entropie amortie · Pose des en-têtes sortants : 10 en-têtes par réponse · Écriture sur la socket : Node — ~5 % Trajet d'une requête — largeur ∝ coût relatif structurel (assumé) accidentel (traité) Analyse HTTP entrante Node — 8 % Écouteurs Node Node — 9 %, 94 % des attaches Portée d'injection ~2 µs réels (sonde) Fabrique de contexte allégée — non re-profilée depuis Analyse d'URL une seule analyse par requête Résolution de route 2,8 motifs — 0,6 % du budget Pare-feu (zones, CSRF, en-têtes) entropie amortie Pose des en-têtes sortants 10 en-têtes par réponse Écriture sur la socket Node — ~5 %
% CPU Écouteurs Node Analyse HTTP entrante Pose des en-têtes sortants Écriture sur la socket Portée d'injection Code du noyau HTTP Ramasse-miettes Résolution de route Reformatage d'URL Nonce CSP Armements de délai 0,0 2,0 4,0 6,0 8,0 10,0 12,0 9,0 8,0 7,3 5,0 4,4 3,47 1,2 0,6 0,16 0,16 0,05 Part de CPU par poste, sur une requête servie Gris : ce que Node fait de toute façon. Couleur : ce que le framework ajoute.
% CPU Écouteurs Node Analyse HTTP entrante Pose des en-têtes sortants Écriture sur la socket Portée d'injection Code du noyau HTTP Ramasse-miettes Résolution de route Reformatage d'URL Nonce CSP Armements de délai 0,0 2,0 4,0 6,0 8,0 10,0 12,0 9,0 8,0 7,3 5,0 4,4 3,47 1,2 0,6 0,16 0,16 0,05 Part de CPU par poste, sur une requête servie Gris : ce que Node fait de toute façon. Couleur : ce que le framework ajoute.
Écouteurs Node9,00 %structurel
Analyse HTTP entrante8,00 %structurel
Pose des en-têtes sortants7,30 %propre au framework
Écriture sur la socket5,00 %structurel
Portée d'injection4,40 %propre au framework
Code du noyau HTTP3,47 %propre au framework
Fabrique de contextenon re-profilépropre au framework
Ramasse-miettes1,20 %structurel
Résolution de route0,60 %propre au framework
Reformatage d'URL0,16 %propre au framework
Nonce CSP0,16 %propre au framework
Armements de délai0,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érationPar requêteDétail
res.setHeader10,0serveur · nosniff · cadre · référent · CSP · id · traçage · type ×2 · longueur
res.removeHeader3,0type de contenu ×2 (aller-retour) · longueur
socket.setTimeout3,02 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,0un hasHeader maison qui copiait tout
Écouteurs attachés4,0fermeture ×2 · fin · terminé — majoritairement Node
res.write + res.end2,0deux é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

µs de scan par requête routes déclarées 0 0 0 0 0 0 500 1 000 1 500 2 000 2 500
µs de scan par requête routes déclarées 0 0 0 0 0 0 500 1 000 1 500 2 000 2 500
136470,09 µs1,3 %
3001010,05 µs3,0 %
6002010,07 µs10,3 %
1 2004010,07 µs36,7 %
2 4008010,06 µs66,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

µs/req Pipeline nu Cycle de session hors ORM Reprise de session — part ORM find() 20 lignes via le dépôt Sérialisation JSON des 20 lignes INSERT via le dépôt 0 200 400 600 800 1 000 86 17 322 850 43 666 Coût par poste, obtenu par soustraction des marches
µs/req Pipeline nu Cycle de session hors ORM Reprise de session — part ORM find() 20 lignes via le dépôt Sérialisation JSON des 20 lignes INSERT via le dépôt 0 200 400 600 800 1 000 86 17 322 850 43 666 Coût par poste, obtenu par soustraction des marches
Cible de banc (contrôle)pipeline nu11 580861,9 %
Session — magasin mémoiresession en mémoire9 6641031,7 %
Session — SQLitesession via l'ORM2 3504261,5 %
Écriture d'une factureINSERT + 2 clés étrangères1 3297521,4 %
Lecture allégéefind() 20 lignes1 0689362,5 %
Lecture complète+ JSON complet1 0229783,0 %
Cycle utilisateur — mémoiresession mémoire + lecture9831 0170,6 %
Cycle utilisateur — SQLitesession ORM + lecture7191 3911,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 40,08 % pilote + exécution 27,75 % Node + V8 14,08 % Nodefony (fr... 11,61 % repos 4,73 % orm-core + sécurité + adapt... 1,75 %
drizzle — construction 40,08 % pilote + exécution 27,75 % Node + V8 14,08 % Nodefony (fr... 11,61 % repos 4,73 % orm-core + sécurité + adapt... 1,75 %
drizzle — construction de la requête39,0 %
pilote drizzle → SQLite (préparation + exécution)27,0 %
Node interne5,5 %
V8 (anonyme / natif)5,1 %
@nodefony/framework4,8 %
repos4,6 %
@nodefony/http3,6 %
V8 (programme / natif)3,1 %
cœur nodefony2,9 %
divers1,5 %
ramasse-miettes V81,1 %
@nodefony/orm-core0,9 %
@nodefony/security + module test + adaptateur0,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

req/s SQLite — lecture SQLite — session + lecture PostgreSQL — lecture PostgreSQL — session + lecture 0 500 1 000 1 500 2 000 2 500 2 019 1 516 1 640 1 021 Débit par moteur et par route
req/s SQLite — lecture SQLite — session + lecture PostgreSQL — lecture PostgreSQL — session + lecture 0 500 1 000 1 500 2 000 2 500 2 019 1 516 1 640 1 021 Débit par moteur et par route
RouteSQLitePostgreSQL
Lecture allégée2 0191 640
Session + lecture1 5161 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.
MoteurCe qui se passe réellementD'où vient le gain
SQLiteInstruction native compilée une fois, réutiliséecompilation + JavaScript
PostgreSQLRequête nommée ; plan mis en cache PAR CONNEXION du poolJavaScript, surtout
MySQLLe pilote passe par client.query() — aucune préparation protocoleJavaScript 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.

Latence et blocage de la boucle d'événementsSQLite occupe la boucle pendant toute sa requête (133 ms) ; PostgreSQL ne l'occupe que 0,22 ms sur 503 ms de requête, le reste étant de l'attente pendant laquelle le serveur sert d'autres requêtes. La boucle d'événements pendant une requête (échelle : 0 à 520 ms) boucle OCCUPÉE — plafonne le processus attente — gratuite SQLite synchrone 133 ms bloqués PostgreSQL asynchrone 0,22 ms bloqués sur 503 Le rappel armé avant la requête part après 134 ms côté SQLite, après 0,22 ms côté PostgreSQL.
PiloteDurée de la requêteRetard du rappel arméVerdict
SQLite (better-sqlite3)133,00 ms134,00 msbloque la boucle
PostgreSQL (pg_sleep 0,5 s)503,00 ms0,22 msne 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.
µs/req SQLite — latence SQLite — CPU de boucle PostgreSQL — latence PostgreSQL — CPU de boucle 0 300 600 900 1 200 1 500 22 24 1 232 194 Latence contre CPU de boucle (échelle logarithmique) La latence PostgreSQL est 56× celle de SQLite ; son CPU de boucle, 8×
µs/req SQLite — latence SQLite — CPU de boucle PostgreSQL — latence PostgreSQL — CPU de boucle 0 300 600 900 1 200 1 500 22 24 1 232 194 Latence contre CPU de boucle (échelle logarithmique) La latence PostgreSQL est 56× celle de SQLite ; son CPU de boucle, 8×
PiloteLatenceCPU de bouclePlafond théorique d'un processus
SQLite synchrone22 µs24 µs~41 700 req/s
PostgreSQL asynchrone1 232 µs194 µs~5 100 req/s
Les quatre instruments qui ont menti sur cette seule question
InstrumentLe viceLe 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)
monitorEventLoopDelayrésolution de l'ordre de la milliseconderend son propre plancher pour les DEUX pilotes
Une colonne « bloque ? non »assertion jamais mesurée, présentée comme un résultatle plus dangereux : il ressemble à une réponse
process.cpuUsage()compte TOUS les fils, ramasse-miettes compris110 % du temps mural sur une réponse volumineuse
Un banc qui n'a pas mesuré doit se taire, pas répondre « non ».

7 · Dimensionnement — ce que tient un pod

débit (req/s) connexions simultanées 1 000 1 100 1 200 1 300 1 400 1 500 1 600 1 700 0 20 40 60 80 100 Lecture — débit Lecture connectée — débit
débit (req/s) connexions simultanées 1 000 1 100 1 200 1 300 1 400 1 500 1 600 1 700 0 20 40 60 80 100 Lecture — débit Lecture connectée — débit
latence (ms) connexions simultanées 0 30 60 90 120 150 180 0 20 40 60 80 100 Lecture — p99 Lecture — p50
latence (ms) connexions simultanées 0 30 60 90 120 150 180 0 20 40 60 80 100 Lecture — p99 Lecture — p50
Débit — lectureDébit — lecture connectéeLatence p99Latence p50
Lecture251 64213,7 ms27,4 ms
Lecture501 66327,8 ms49,1 ms
Lecture1001 59759,6 ms163,8 ms
Lecture connectée251 03121,7 ms44,5 ms
Lecture connectée501 05443,2 ms75,9 ms
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 CPUComparer une fenêtre bridée à une fenêtre libre×1,62 à code identique
Niveau thermiqueComparer un run froid à un run chaudpeut INVERSER un verdict (−2 % vs +10 %)
Indexation systèmeMesurer pendant une réindexation11–22 % de CPU par vagues
Docker arrêtéMesurer avec un conteneur inactif64 % de CPU observés
Purge des résultatsUn résultat d'un autre lot dans la comparaison
LC_ALL=CUne garde numérique muette (« 4,1 » vs « 4.1 »)muette entre 3 et 4 %
Un seul serveurUn superviseur résiduel qui tient le port
Pas de pause longueLa 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 chargeValeur
Machine virtuelle, vue de l'hôte~685 % (plafond pratique)
Proxy com.docker.backend~152 %
Processus Node~50 %
Enveloppe disponible6 cœurs physiques
Facteur intérieur / extérieur3,7 (16 222 tps dedans vs ~4 400 depuis l'hôte)
0 10 20 30 40 50 60 70 80 90 100 85,63 % Charge de la VM Docker (≈685 % sur ~800 % pratiques)
0 10 20 30 40 50 60 70 80 90 100 85,63 % Charge de la VM Docker (≈685 % sur ~800 % pratiques)
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-mesurerchiffres non rattachés à un état de code — retirés de ce rapport tant qu'ils ne le sont pas
Profil de la fabrique de contexteNon re-profiléeallégée depuis la dernière mesure de profil : sa part actuelle n'est pas connue
Absolus PostgreSQLNon transposablespris derrière une virtualisation réseau à facteur 3,7
Attribution fine du chemin virtualiséNon établieordre de grandeur mesuré, décomposition non
Renouvellement WebSocketNon concluantmétrique à rampe ; non-régression établie, gain non
Mémoire par socket sécuriséeÉcartéerégression sans qualité d'ajustement (R² = 0,25)
Mise à jour / insertion-remplacement ORMNon entaméhors du chemin chaud des bancs actuels
Index sur les clés étrangères au scaffoldQuestion produitle générateur doit-il les indexer par défaut ?
MySQL — requêtes préparéesNon 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émentValeur
MachineMacBook Pro — Intel Core i9-8950HK @ 2,90 GHz, 6 cœurs physiques / 12 logiques, 32 Go
SystèmemacOS 15.7.7 (Darwin 24.6)
Serveurmono-processus, NODE_ENV=production, boucle locale, NF_LOG_DRIVER=null
Générateur de chargewrk 4.2.0 [kqueue], -t4
Famille de bancsNodeChargeRoutes
Pipeline HTTP (A/B et profils)v26.5.0-c128, 10 s136
Comparatif de frameworksv26.5.0-c128, 10 s186
ORM et bases de donnéesv26.7.0-c25, 7 s186
# 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/.