Le constat
EnglishUp affiche des quiz de leçons d'anglais, chaque question a ses réponses possibles et sa bonne réponse. En local avec peu de données, tout semblait rapide. En production, avec quelques centaines de questions, l'endpoint GET /api/lessons/:id/quiz mettait 2,3 secondes à répondre pour 20 questions. Inacceptable pour une expérience de quiz censée être fluide.
Le code coupable
Le repository récupérait la leçon, puis bouclait sur ses questions pour aller chercher les réponses une par une :
// AVANT : une requête par question = N+1
async function getQuizForLesson(lessonId) {
const lesson = await prisma.lesson.findUnique({ where: { id: lessonId } });
const questions = await prisma.question.findMany({ where: { lessonId } });
for (const q of questions) {
q.answers = await prisma.answer.findMany({ where: { questionId: q.id } }); // N requêtes !
}
return { lesson, questions };
}
Pour 20 questions, ça fait 22 requêtes SQL séquentielles (1 leçon + 1 liste de questions + 20 fois les réponses), chacune avec sa latence réseau vers MySQL. Le classique problème N+1, mais avec Prisma qui rend le code tellement lisible qu'on ne le voit pas venir.
Le diagnostic
Prisma expose un logger de requêtes très utile pour ce genre de cas :
const prisma = new PrismaClient({
log: [{ emit: 'event', level: 'query' }],
});
prisma.$on('query', (e) => {
console.log(`${e.query} — ${e.duration}ms`);
});
La console a affiché 22 lignes quasi identiques pour un seul appel API. Le pattern était évident dès les premiers logs.
Le correctif : include au lieu de boucler
Prisma permet de charger les relations en une seule requête via include, en générant un JOIN (ou plusieurs requêtes optimisées en parallèle selon le moteur, mais toujours en un aller-retour logique) :
// APRÈS : une seule requête avec include imbriqué
async function getQuizForLesson(lessonId) {
return prisma.lesson.findUnique({
where: { id: lessonId },
include: {
questions: {
include: { answers: true },
orderBy: { position: 'asc' },
},
},
});
}
Résultat : de 22 requêtes à 1 seule, et le temps de réponse est passé de 2,3s à 90ms.
Ce que je retiens
- Le N+1 se cache facilement derrière du code Prisma propre et lisible — la lisibilité n'est pas une garantie de performance.
- Activer le logger de requêtes en développement, même temporairement, révèle instantanément les patterns N+1.
includeimbriqué remplace élégamment les boucles avecawaità l'intérieur — un réflexe à avoir dès qu'on voit unforautour d'un appel Prisma.- Sur une relation 1-N-N comme leçon → questions → réponses, un seul
includebien structuré suffit à tout charger d'un coup.