Optimiser le temps de réponse d’une API GraphQL
Livrer une API GraphQL rapide n’est pas qu’une question d’infrastructure. La latence se joue à chaque couche : schéma, résolveurs, accès base de données, réseau, cache et observabilité. Voici un guide pratique et actionnable pour viser un P95 < 200 ms (ou mieux) en production.
1) Mesurer avant d’optimiser
- Objectif: définir des SLO (par ex. P95 < 200 ms, P99 < 500 ms) et des budgets par couche (parsing, exécution, DB, réseau).
- Trace des résolveurs: mesurez le temps par champ pour détecter les goulots d’étranglement (N+1, champs coûteux).
- Outils: OpenTelemetry, Apollo Studio, Prometheus + Grafana.
// Exemple: plugin Apollo Server pour tracer la durée des résolveurs
import { ApolloServer } from '@apollo/server';
const timingPlugin = {
async requestDidStart() {
return {
async willResolveField({ info }) {
const start = performance.now();
return () => {
const ms = Math.round(performance.now() - start);
console.log(`[resolver] ${info.parentType.name}.${info.fieldName} -> ${ms}ms`);
};
},
};
},
};
const server = new ApolloServer({ typeDefs, resolvers, plugins: [timingPlugin] });
2) Éliminer le N+1 avec DataLoader et des sélections ciblées
Le problème N+1 survient quand un résolveur charge un enregistrement, puis un autre pour chaque enfant, etc. Résultat: des dizaines/centaines de requêtes DB au lieu d’une requête batch.
// DataLoader par requête (à placer dans le context de la requête GraphQL)
import DataLoader from 'dataloader';
import { db } from './db';
function createUserByIdLoader() {
return new DataLoader(async (userIds: readonly string[]) => {
const rows = await db.user.findMany({
where: { id: { in: userIds as string[] } },
});
const byId = new Map(rows.map(r => [r.id, r]));
return userIds.map(id => byId.get(id) ?? null);
});
}
// contexte par requête
export type GraphQLContext = { loaders: { userById: ReturnType<typeof createUserByIdLoader> } };
export function createContext(): GraphQLContext {
return { loaders: { userById: createUserByIdLoader() } };
}
// résolveur évitant N+1
const resolvers = {
Post: {
author: (post: { authorId: string }, _: unknown, ctx: GraphQLContext) =>
ctx.loaders.userById.load(post.authorId),
},
};
Conseils complémentaires:
- Sélections ciblées: utilisez
select/includede votre ORM (Prisma, TypeORM) en fonction duselectionSetpour ne récupérer que les colonnes nécessaires. - Cache par requête: DataLoader intègre un cache in-request pour dédupliquer les accès identiques.
3) Batch et parallélisation contrôlée
- Batch: regroupez les accès par type (ex:
findManyavecIN) via DataLoader. - Parallélisez: exécutez les appels indépendants en parallèle, mais limitez la concurrence pour ne pas saturer la DB.
import pLimit from 'p-limit';
const limit = pLimit(10); // limite de concurrence
const [profile, orders] = await Promise.all([
limit(() => loaders.profileById.load(userId)),
limit(() => loaders.ordersByUserId.load(userId)),
]);
4) Base de données: requêtes et index taillés pour GraphQL
- Index adéquats: créez des index sur les colonnes filtrées/joinées, préférez des index couvrants quand c’est pertinent.
- EXPLAIN (ANALYZE): profilez les requêtes critiques (celles appelées par des champs populaires).
- Pagination par curseur: évitez
offsetcoûteux sur de grandes tables; utilisez un schémaConnection.
type PostEdge { node: Post! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String }
type PostConnection { edges: [PostEdge!]! pageInfo: PageInfo! }
type Query {
posts(first: Int = 20, after: String): PostConnection!
}
5) Contrôler les champs coûteux et forcer la pagination
- Pas de listes non paginées: mettez des limites par défaut et des plafonds (ex:
first <= 100). - Champs lourds: calculez en différé, servez en streaming (
@defer/@streamsi votre stack le supporte) ou mettez en cache séparément. - Dé-normalisez prudemment: pour éviter des jointures systématiques sur des champs affichés partout (ex: counts).
6) Cache multi-couches: champ, requête, CDN
- Cache champ (niveau résolveur): Redis/Memcached pour des agrégations stables (TTL + invalidation par clé).
import { redis } from './redis';
async function getTopCategories(): Promise<string[]> {
const key = 'top-categories:v1';
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const rows = await db.$queryRaw`SELECT name FROM category ORDER BY popularity DESC LIMIT 10`;
const value = rows.map((r: any) => r.name);
await redis.set(key, JSON.stringify(value), { EX: 300 }); // 5 min
return value;
}
- Cache requête (réponse GraphQL): activez les requêtes persistées (APQ) et servez en GET avec des headers
Cache-Controlpour permettre un cache proxy/CDN.
// Apollo Server avec APQ (Automatic Persisted Queries)
import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled';
import { ApolloServerPluginCacheControl } from '@apollo/server/plugin/cacheControl';
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
ApolloServerPluginLandingPageDisabled(),
ApolloServerPluginCacheControl({ defaultMaxAge: 60 }), // 60s par défaut
],
});
- CDN: mettez le CDN en frontal (GET + APQ) pour cache global sur les requêtes fréquentes et publiques.
Bonnes pratiques d’invalidation:
- Clés nommées et stables (
entity:{id}:field), TTL raisonnables. - Invalidation sur mutation (ex: après
createPost, purgerposts:list:*).
7) Sécurité de performance: requêtes persistées, whitelisting, complexité
- APQ/whitelisting: n’autorisez que des requêtes connues en prod, pour supprimer le coût de parsing/validation et éliminer les requêtes malicieuses.
- Limite de profondeur et complexité: rejetez les requêtes trop profondes/coûteuses.
import { getComplexity, simpleEstimator, fieldExtensionsEstimator } from 'graphql-query-complexity';
import depthLimit from 'graphql-depth-limit';
// Au moment de la validation
const maxDepth = depthLimit(10);
function computeComplexity(query, variables) {
return getComplexity({
schema,
query,
variables,
estimators: [fieldExtensionsEstimator(), simpleEstimator({ defaultComplexity: 1 })],
});
}
// Refuser si > seuil (ex: 500)
8) Réseau et runtime
- HTTP/2 + Keep-Alive + Brotli: réduisez la latence de transport; servez
application/jsoncompressé. - Pool de connexions DB: taille adaptée,
pgBouncer(PostgreSQL) en mode transaction. - Runtime Node.js: Fastify plutôt qu’Express pour un overhead plus faible; évitez les middlewares superflus.
- Conteneurs: CPU/IO stables, épingles CPU si nécessaire; activez l’auto-scaling.
Niveau Node/Apollo:
- désactivez la landing page en prod, activez la cache policy.
- sérialisation JSON rapide (ex:
fast-json-stringifysi applicable).
9) Services tiers: timeouts, retries, circuit breakers
- Timeout par appel (ex: 800 ms), retries avec backoff et circuit breaker pour isoler un tiers lent.
- Cache des réponses tierces (TTL court) pour éviter de propager les lenteurs.
import { setTimeout as delay } from 'timers/promises';
async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return await Promise.race([
p,
(async () => { await delay(ms); throw new Error('timeout'); })(),
]);
}
10) Federation et gateways
Si vous utilisez Apollo Federation:
- DataLoader dans chaque sous-graph pour éviter le N+1.
@requires/@providesavec parcimonie; évitez les traversées profondes inter-services.- Supergraph cache: activez le cache au niveau gateway et les APQ.
Checklist rapide
- Mesure: trace par résolveur, P95/P99, budgets de latence.
- N+1: DataLoader par requête; requêtes batch.
- DB: index utiles,
EXPLAIN, pagination par curseur. - Pagination: limites par défaut et plafonds.
- Cache: Redis ou in-memory, invalidation sur mutation.
- Sécurité de perf: profondeur, complexité, whitelisting.
- Réseau: HTTP/2, keep-alive, compression; pools DB.
- Tiers: timeouts, retries, circuit breaker.
- Observabilité: logs structurés, traces, dashboards SLO.
💡 Besoin d’auditer et d’optimiser votre API GraphQL ? Notre équipe intervient sur le schéma, les résolveurs, la base de données et l’infra pour gagner des millisecondes à chaque étape.
📅 Réservez un audit performance et passons votre P95 sous la barre des 100 ms.