
Nouveau verbe HTTP : QUERY
Vous étiez coincé pour vos recherches volumineuses entre un GET à rallonge ou détourner le POST dans vos APIs, QUERY est là pour répondre à ce besoin

Sommaire
•
Qu'est-ce qu'un problème N+1 en GraphQL ?
•
Le scénario GraphQL utilisé
•
Reproduire le N+1 localement
•
Détecter le N+1 avec NestJS Profiler
•
Corriger le N+1 GraphQL avec DataLoader
•
Vérifier le gain avec NestJS Profiler
•
DataLoader n'est pas toujours la solution
•
Questions fréquentes
•
Tester NestJS Profiler
Une query GraphQL NestJS peut sembler rapide tout en exécutant beaucoup trop d'allers-retours vers vos dépendances.
C'est le problème N+1 : un résolveur de champ imbriqué déclenche une lecture supplémentaire pour chaque élément renvoyé. Tant que le jeu de données est petit et que les dépendances répondent vite, ce défaut peut rester invisible. Il devient coûteux dès que le catalogue, la latence réseau ou le trafic augmentent.
Dans ce tutoriel, nous allons détecter un N+1 dans une API GraphQL NestJS, le corriger avec DataLoader, puis vérifier le résultat avec NestJS Profiler.
Sur l'application d'exemple, cette query interroge 3 systèmes différents. Elle passe de 10 allers-retours à 3 : une requête SQL, une requête MongoDB et un appel HTTP.
À propos des mesures
Les chiffres de cet article proviennent de l'application d'exemple de NestJS Profiler, exécutée localement. Ils comparent 2 stratégies sur le même scénario et ne constituent pas un benchmark universel : les durées dépendent de la machine, de l'état des services et de la latence de l'API externe. Le nombre d'allers-retours reste la mesure la plus robuste.
| Avant DataLoader | Après DataLoader | |
|---|---|---|
| Requêtes SQL | 1 | 1 |
| Requêtes MongoDB | 4 | 1 |
| Appels HTTP sortants | 5 | 1 |
| Total des allers-retours | 10 | 3 |
| Tags du profiler | N+1 ×4, N+1 ×5 | Aucun |
Un N+1 apparaît lorsqu'une query récupère une liste, puis qu'un résolveur de champ imbriqué déclenche une lecture supplémentaire pour chaque élément de cette liste.
Dans notre exemple, la query récupère des produits, les reviews associées à chaque produit, puis l'auteur de chaque review :
1 requête → liste des produits N requêtes → reviews de chaque produit M requêtes → auteur de chaque review
Avec 4 produits et 5 reviews, cela donne :
1 + 4 + 5 = 10 allers-retours
Le danger est que le code n'a pas besoin de changer pour que le problème grossisse. C'est la taille du résultat demandé par le client qui décide du nombre de lectures.
Avec 20 produits, notre scénario déclenche 26 allers-retours :
1 requête SQL + 20 requêtes MongoDB + 5 appels HTTP
Un N+1 n'est donc pas forcément une query lente sur un petit jeu de données. C'est surtout une complexité qui évolue avec le nombre d'éléments retournés.
L'application d'exemple de NestJS Profiler simule un backend de marketplace découpé en contextes métier. La query de démonstration est la suivante :
query products { products { id name description inStock price createdAt reviews { id author { id name company } comment rating createdAt } } }
Elle traverse 3 sources de données, une par champ résolu :
| Champ GraphQL | Résolveur | Source de données |
|---|---|---|
products | ProductResolver | PostgreSQL, avec TypeORM ou MikroORM |
Product.reviews | ProductReviewsResolver | MongoDB, avec Mongoose |
Review.author | ReviewAuthorResolver | API HTTP externe d'annuaire utilisateurs |
@Resolver(() => ProductType) export class ProductReviewsResolver { constructor(private readonly loader: ProductReviewsLoader) {} @ResolveField(() => [ReviewType]) reviews(@Parent() product: ProductType): Promise<Review[]> { return this.loader.load(String(product.id)); } }
@Resolver(() => ReviewType) export class ReviewAuthorResolver { constructor(private readonly loader: ReviewerLoader) {} @ResolveField(() => ReviewAuthorType, { nullable: true }) author(@Parent() review: ReviewType): Promise<Reviewer | null> { return this.loader.load(review.authorId); } }
Chaque résolveur est correct pris isolément : il ne connaît que l'élément qu'il doit résoudre. C'est l'exécution combinée de l'arbre GraphQL qui transforme ces lectures individuelles en N+1.
Pour comprendre plus précisément comment NestJS exécute un résolveur et traverse les différentes couches de l'application, consultez le cycle de vie d'une requête NestJS.
Le dépôt contient l'application d'exemple complète. Pour lancer le scénario :
git clone https://github.com/eleven-labs/nest-profiler.git cd nest-profiler pnpm install docker compose up -d cp examples/api/.env.example examples/api/.env pnpm example:dev
Le fichier .env.example active les modules nécessaires au scénario :
FEATURE_GRAPHQL=true FEATURE_MONGOOSE=true FEATURE_DATALOADER=false
Le flag FEATURE_DATALOADER=false est volontaire : nous commencerons par observer le comportement non optimisé, puis nous le basculerons à true.
Une fois l'application démarrée :
http://localhost:3000/graphql ouvre Apollo Sandbox.http://localhost:3000/_profiler ouvre NestJS Profiler.http://localhost:3000/api ouvre Swagger pour explorer le reste de l'exemple.Envoyez la query products depuis Apollo Sandbox.
NestJS Profiler associe un token à chaque exécution. Après la query GraphQL, les headers de réponse permettent de retrouver directement le profil concerné :
X-Debug-Token: ebab37ec-3ace-4569-8890-8360ee9e0d3a X-Debug-Token-Link: /_profiler/ebab37ec-3ace-4569-8890-8360ee9e0d3a
Ouvrez X-Debug-Token-Link, ou accédez à /_profiler puis filtrez les profils GraphQL.
NestJS Profiler rassemble les informations techniques d'une même exécution : opération GraphQL, trace d'exécution, requêtes SQL et MongoDB, appels HTTP sortants, logs et exceptions. Ici, l'important est de commencer par la trace d'exécution, puis de confirmer les répétitions dans les panneaux de détail.

L'onglet Performance affiche une execution trace qui remet les événements dans leur ordre d'exécution. Avec le filtre I/O only, elle ne conserve que les opérations qui sortent du processus : SQL, MongoDB, HTTP, cache et autres dépendances externes.
Dans le profil initial, la trace fait apparaître :
find.
La répétition se voit immédiatement : 4 opérations MongoDB similaires, puis 5 appels vers l'annuaire externe. Les 2 étages se recouvrent partiellement, car chaque appel HTTP démarre dès que les reviews de son produit sont revenues, sans attendre les autres.
La trace permet aussi de distinguer 2 notions souvent confondues :
Le panneau Database, sous-onglet MongoDB, confirme que le résolveur Product.reviews déclenche une opération find par produit.

Chaque ligne indique notamment :
Une des requêtes de l'exemple ne retourne aucun avis. Elle reste pourtant un aller-retour complet vers MongoDB. Plus le catalogue grandit, plus ce coût augmente.
Le panneau HTTP Client montre les appels effectués par Review.author.

Les 5 appels relèvent d'un problème de batching : l'API externe est interrogée individuellement pour chaque auteur.
Mais le profil révèle aussi un doublon : /users/1 est demandé 2 fois pendant la même opération GraphQL. C'est un problème de déduplication.
Ces 2 problèmes appellent une même solution dans notre cas : un DataLoader scopé à la requête, capable de grouper les clés et de mémoriser celles déjà demandées.
DataLoader collecte les clés demandées pendant la même phase de résolution GraphQL, puis appelle une fonction de batch avec l'ensemble de ces clés.
Il apporte 2 bénéfices dans notre scénario :
La stratégie initiale appelle le service à chaque résolution :
@Injectable() export class DirectProductReviewsLoader implements ProductReviewsLoader { constructor(private readonly reviews: ReviewService) {} load(productId: string): Promise<Review[]> { return this.reviews.findByProduct(productId); } }
Chaque produit entraîne donc son propre find({ productId }).
La version DataLoader collecte les identifiants de produits et appelle une unique méthode findByProducts() qui construit un filtre MongoDB avec $in.
@Injectable({ scope: Scope.REQUEST }) export class DataLoaderProductReviewsLoader implements ProductReviewsLoader { private readonly loader: DataLoader<string, Review[]>; constructor(reviews: ReviewService) { this.loader = new DataLoader<string, Review[]>(async (productIds) => { const found = await reviews.findByProducts(productIds); const byProduct = new Map<string, Review[]>(); for (const review of found) { const current = byProduct.get(review.productId); if (current) { current.push(review); } else { byProduct.set(review.productId, [review]); } } return productIds.map((productId) => byProduct.get(productId) ?? []); }); } load(productId: string): Promise<Review[]> { return this.loader.load(productId); } }
Le contrat de DataLoader
Le tableau retourné doit garder le même ordre que les clés demandées : une position du tableau correspond à une clé donnée, même lorsqu'aucun résultat n'est trouvé. C'est la source d'erreur la plus fréquente lors de l'écriture d'une fonction de batch.
Batcher les reviews est aussi ce qui permet de batcher les auteurs : quand toutes les reviews sont disponibles dans la même phase de résolution, les identifiants d'auteurs peuvent être regroupés par un second DataLoader.
Sans DataLoader : products → reviews(product 1) → authors(product 1) → reviews(product 2) → authors(product 2) Avec DataLoader : products → reviews(product 1, 2, 3, 4) → authors(1, 2, 3, 4)
Voici une implémentation type pour ce second loader :
@Injectable({ scope: Scope.REQUEST }) export class DataLoaderReviewerLoader implements ReviewerLoader { private readonly loader: DataLoader<number, Reviewer | null>; constructor(private readonly reviewers: ReviewerService) { this.loader = new DataLoader<number, Reviewer | null>(async (authorIds) => { const authors = await this.reviewers.findByIds([...authorIds]); const byId = new Map(authors.map((author) => [author.id, author])); return authorIds.map((authorId) => byId.get(authorId) ?? null); }); } load(authorId: number): Promise<Reviewer | null> { return this.loader.load(authorId); } }
Les 2 loaders sont déclarés avec @Injectable({ scope: Scope.REQUEST }), et ce n'est pas un détail : le cache de DataLoader est ainsi limité à l'opération en cours. Il évite les appels dupliqués dans cette opération sans conserver des résultats obsolètes, ni réutiliser des données dans un autre contexte utilisateur ou d'autorisation.
Dans examples/api/.env :
FEATURE_DATALOADER=true
Relancez ensuite l'application, envoyez exactement la même query et ouvrez le nouveau profil avec son X-Debug-Token-Link.
La correction n'est terminée que lorsqu'elle est vérifiée sur la même opération.
Après activation de DataLoader, l'execution trace ne montre plus que :
$in.
Les tags N+1 disparaissent du profil.
| Avant DataLoader | Après DataLoader | |
|---|---|---|
| Requêtes SQL | 1 | 1 |
| Requêtes MongoDB | 4 | 1 |
| Appels HTTP sortants | 5 | 1 |
| Total des allers-retours | 10 | 3 |
| Temps réseau HTTP cumulé | 125 ms | 12 ms |
| Durée avec 4 produits | 25 à 46 ms | 18 à 24 ms |
| Durée avec 20 produits | Environ 132 ms | Environ 21 ms |
| Tags de performance | N+1 ×4, N+1 ×5 | Aucun |

Le gain de latence est modéré avec 4 produits, car les appels initiaux partaient déjà en parallèle. Avec 20 produits, la différence devient beaucoup plus nette.
Le résultat important est structurel : le nombre d'accès à MongoDB ne dépend plus du nombre de produits renvoyés, et les appels HTTP sont regroupés et dédupliqués.
DataLoader est adapté lorsqu'une opération GraphQL résout plusieurs clés individuelles et que la source de données peut répondre à ces clés en lot.
DataLoader n'est pas un cache applicatif
Son objectif est de limiter les lectures répétées à l'intérieur d'une même opération GraphQL. Pour partager des données entre plusieurs requêtes, il faut un cache applicatif avec sa propre stratégie d'invalidation.
Dans d'autres cas, une autre approche peut être plus pertinente :
| Situation | Approche à privilégier |
|---|---|
| Relation SQL connue avant l'exécution | Jointure, relations, populate ou requête dédiée |
| Même donnée demandée entre plusieurs requêtes HTTP | Cache applicatif avec TTL |
| API externe sans endpoint de batch | Cache par requête pour dédupliquer les appels |
| Très grand nombre de résultats | Pagination, limitation de profondeur ou query complexity |
| Query lente sans requêtes répétées | Index, EXPLAIN, optimisation SQL ou MongoDB |
| Données dépendantes des droits de l'utilisateur | Loader scopé à la requête et clés incluant le contexte nécessaire |
Il faut compter les lectures réellement émises pendant une opération, et non seulement lire les résolveurs. NestJS Profiler regroupe les requêtes répétées par empreinte et les marque dans la trace ainsi que dans les panneaux Database ou HTTP Client.
Non. Il faut que la source puisse servir plusieurs clés en une fois. Sans endpoint de batch côté API externe, un DataLoader peut toujours dédupliquer une même clé, mais il ne peut pas réduire plusieurs clés distinctes à un seul appel.
Le cache interne de DataLoader est conçu pour la durée d'une opération GraphQL. Un loader partagé entre requêtes risque de réutiliser des données obsolètes, de retenir inutilement de la mémoire ou de mélanger des contextes d'autorisation.
NestJS Profiler est principalement conçu pour le développement et les environnements contrôlés. Les profils peuvent inclure des URLs, paramètres, payloads, logs, erreurs ou données de sécurité. Activez-le conditionnellement et ne rendez pas son interface accessible publiquement.
Un APM et OpenTelemetry servent généralement à suivre l'état d'un système déployé dans la durée. NestJS Profiler est orienté développement : il permet d'examiner une exécution précise dans son contexte complet, puis de rejouer le scénario après une modification. Les approches sont complémentaires.
NestJS Profiler est un projet open source inspiré du Symfony Web Profiler. Il permet de visualiser une exécution NestJS dans /_profiler et d'y relier des collecteurs modulaires : GraphQL, TypeORM, MikroORM, Mongoose, HTTP, cache, validation, sécurité, logs, RabbitMQ, CLI et plus encore.
Pour aller plus loin :
Un N+1 n'est pas forcément visible dans les temps de réponse locaux. En revanche, il laisse une trace : des appels répétitifs que personne n'a écrits explicitement, mais que NestJS Profiler rend visibles.
Avec 1 query, 2 profils et des DataLoaders, vous pouvez passer d'une intuition à une optimisation mesurée.
Auteur(s)
Fabien Pasquet
Développeur Full Stack JS @ElevenLabs. Technologies de prédilection : Typescript, GraphQL, NodeJS et React
Vous souhaitez en savoir plus sur le sujet ?
Organisons un échange !
Notre équipe d'experts répond à toutes vos questions.
Nous contacterDécouvrez nos autres contenus dans le même thème

Vous étiez coincé pour vos recherches volumineuses entre un GET à rallonge ou détourner le POST dans vos APIs, QUERY est là pour répondre à ce besoin

Sécuriser votre serveur avec des certificat SSL pour tous vos sous-domaines grâce au DNS challenge

Ce guide présente Apache Iceberg, un format de table moderne pour les données volumineuses, la gestion des versions et des performances optimisées.