Koddey
Prendre rendez-vous

Optimiser le temps de réponse d’une API GraphQL

#graphql#performance#backend#nodejs

Temps de lecture: 6 min

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/include de votre ORM (Prisma, TypeORM) en fonction du selectionSet pour 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: findMany avec IN) 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 offset coûteux sur de grandes tables; utilisez un schéma Connection.
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/@stream si 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

  1. 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;
}
  1. Cache requête (réponse GraphQL): activez les requêtes persistées (APQ) et servez en GET avec des headers Cache-Control pour 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
  ],
});
  1. 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, purger posts: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/json compressé.
  • 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-stringify si 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/@provides avec 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.