Tous les articles
·Ingénierie·9 min

Optimiser les requêtes Prisma dans Next.js : N+1, select et connection pooling

Éliminez le problème N+1, choisissez entre include et select, et configurez le connection pooling Prisma pour tenir en charge en production.

Les performances d'une application Next.js reposent en grande partie sur la qualité des requêtes base de données. Avec Prisma ORM, trois problèmes reviennent systématiquement en production : le problème N+1, la surcharge de données inutiles, et l'épuisement du pool de connexions.

Schéma de requêtes base de données optimisées avec Prisma ORM
Chaque requête compte : l'optimisation Prisma commence dès la conception du schéma.

Le problème N+1 : comment le détecter et le corriger

Le problème N+1 se produit lorsque l'application exécute une requête initiale pour récupérer une liste, puis une requête supplémentaire pour chaque élément. Pour 100 articles avec leurs auteurs, cela représente 101 requêtes au lieu d'une seule.

Pour détecter ce problème, activez les logs de requêtes Prisma dès le développement :

const prisma = new PrismaClient({
  log: ['query', 'warn', 'error'],
})

Si vous constatez une série de SELECT * FROM "User" WHERE id = $1 après un findMany sur des posts, vous avez un N+1 en production.

La correction est directe : remplacer la boucle par un include ou un select imbriqué.

// Avant : 1 + N requêtes
const posts = await prisma.post.findMany()
for (const post of posts) {
  const author = await prisma.user.findUnique({ where: { id: post.authorId } })
}

// Après : 1 seule requête
const posts = await prisma.post.findMany({
  include: {
    author: { select: { id: true, name: true, avatar: true } },
  },
})

include vs select : ne charger que ce dont on a besoin

include et select sont tous deux des mécanismes d'eager loading dans Prisma. La différence clé : include retourne tous les champs du modèle principal et ajoute les relations, tandis que select donne un contrôle total sur chaque champ retourné, y compris ceux du modèle principal.

ApprocheChamps retournésRelationsCas d'usage recommandé
findMany() sans optionTous les champsAucunePrototype, back-office interne
include: { relation: true }Tous les champs + relation complèteOuiQuand tous les champs sont nécessaires
select: { champ: true }Champs choisis uniquementNon par défautAPI publique, liste paginée
select avec select imbriquéChamps choisis + champs relation choisisOui, sélectifsOptimisation maximale

Pour une API qui retourne une liste de 200 articles avec titre, slug et nom d'auteur, la différence entre un findMany() brut et un select ciblé représente une réduction du volume de données de 60 à 80 %.

// Optimisé pour une liste paginée publique
const articles = await prisma.article.findMany({
  take: 20,
  skip: offset,
  orderBy: { publishedAt: 'desc' },
  select: {
    id: true,
    title: true,
    slug: true,
    publishedAt: true,
    author: {
      select: { name: true, avatar: true },
    },
    _count: { select: { comments: true } },
  },
})

La règle de base : ne jamais retourner plus de données que ce que le client affiche. Chaque champ superflu a un coût réseau, un coût mémoire, et un coût de sérialisation JSON.

Stratégies de jointure : join vs query

Depuis Prisma 5.x, il est possible de choisir explicitement la stratégie de chargement des relations via relationLoadStrategy. Deux options existent : join (par défaut sur PostgreSQL) et query.

La stratégie join effectue un LATERAL JOIN ou une sous-requête agrégée en une seule passe SQL. La stratégie query envoie une requête séparée par table et fusionne les résultats côté application. Les deux stratégies sont présentées dans la documentation officielle Prisma.

const users = await prisma.user.findMany({
  relationLoadStrategy: 'join', // ou 'query'
  include: { posts: true },
})

Pour évaluer le gain théorique de la stratégie join, on peut exprimer le nombre de requêtes économisées par la formule suivante :

Requeˆtes eˊconomiseˊes=N×(R1)\text{Requêtes économisées} = N \times (R - 1)

NN est le nombre de lignes dans la requête principale et RR le nombre de relations chargées. Pour 500 utilisateurs avec 2 relations, cela représente 500 requêtes évitées sur chaque appel.

Connection pooling : le pattern singleton

Chaque instance de PrismaClient ouvre ses propres connexions vers la base de données. En développement Next.js, le hot reload crée de nouvelles instances à chaque modification de fichier, ce qui épuise rapidement le pool de connexions.

La solution standard est le pattern singleton avec globalThis :

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'warn'] : ['error'],
  })

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma
}

Ce fichier doit être la seule source d'instanciation de PrismaClient dans tout le projet. Chaque import du module retourne la même instance.

La taille du pool de connexions se configure directement dans la DATABASE_URL :

DATABASE_URL="postgresql://user:password@host:5432/db?connection_limit=10&pool_timeout=20"

La valeur par défaut de Prisma est num_cpus * 2 + 1. Pour un serveur à 2 vCPU, cela donne 5 connexions. Ajuster cette valeur en fonction des limites imposées par la base de données : Supabase impose 60 connexions sur le plan gratuit, PlanetScale a ses propres quotas.

Prisma Accelerate pour les environnements serverless

Les déploiements serverless (Vercel Functions, AWS Lambda, Cloudflare Workers) posent un problème structurel : chaque invocation peut ouvrir une nouvelle connexion, et les connexions ne sont pas partagées entre les instances d'exécution.

Prisma Accelerate résout ce problème en interposant un proxy HTTP entre l'application et la base de données. Toutes les connexions passent par Accelerate, qui maintient un pool persistant côté serveur.

La configuration se limite à installer l'extension et à remplacer l'URL de connexion :

npm install @prisma/extension-accelerate
import { PrismaClient } from '@prisma/client'
import { withAccelerate } from '@prisma/extension-accelerate'

export const prisma = new PrismaClient().$extends(withAccelerate())

Prisma Accelerate inclut aussi du caching de requêtes via deux stratégies : TTL (Time-to-Live) pour les données peu modifiées, et SWR (Stale-While-Revalidate) pour les données acceptant une légère obsolescence. Les deux s'activent directement dans les requêtes :

const categories = await prisma.category.findMany({
  cacheStrategy: { ttl: 60, swr: 30 }, // en secondes
})

Pour les projets avec des données de référence stables (catégories, configurations, tags), ce caching peut éliminer l'essentiel des appels base de données sur les pages à fort trafic.

Ce qu'on en retient

L'optimisation des requêtes Prisma ne se traite pas après le lancement : elle conditionne la tenue en charge dès les premières centaines d'utilisateurs simultanés. Trois réflexes couvrent la majorité des cas en production : éliminer le N+1 avec include ou select imbriqués, limiter les données retournées au strict nécessaire, et ne jamais instancier PrismaClient plus d'une fois par processus.

Pour les projets Next.js déployés en serverless, Prisma Accelerate reste aujourd'hui la solution la plus directe pour éviter l'épuisement des connexions sans toucher à l'architecture applicative.

Sources

  1. Query optimization | Prisma Documentation
  2. Prisma Accelerate : Connection Pooling
  3. Optimizing Connection Pools with PrismaClient Singleton Pattern in Next.js