Guide complet d'optimisation des performances API Next.js : cache, streaming et edge computing

Vendredi soir, 21 h : le product manager poste une capture dans le groupe. Sur la vidéo de test mobile, la liste du blog tourne en Loading pendant 5 secondes, écran blanc. En bas à droite : « C’est un site de quelle époque ? »
J’ouvre Chrome DevTools : l’API met 3200 ms. Panique légère — je savais que c’était lent, mais pas à ce point.
Deux jours plus tard, en creusant les perfs Next.js, ce n’est pas si sorcier. Bonne stratégie de cache + streaming + edge : de 3 s à moins de 500 ms. Surtout, j’ai compris quand utiliser quoi — plus important que de lister les technos.
Aujourd’hui : choix du cache, mise en place du streaming, cas d’usage Edge Functions. Code testé en prod, chiffres mesurés — réutilisables tout de suite.
Pourquoi votre API Next.js est si lente ?
Les goulots classiques. Sur mon API à 3 s :
Requêtes base mal optimisées. Une boucle qui charge l’auteur article par article — le N+1 classique. 100 articles = 100 allers-retours DB. Pire : des tables sans index.
Aucun cache. Chaque refresh = nouvelle requête, recalcul, reformatage. Des configs qui changent une fois par mois, recalculées à chaque seconde.
Tout renvoyé d’un coup. 100 articles avec le corps complet : JSON > 2 Mo, plus d’1 s en réseau. La liste n’a besoin que du titre et du résumé.
Géographie du serveur. Hébergement côte ouest US : RTT depuis la Chine à 200 ms minimum, sans compter le reste.
Next.js 16 et le cache explicite
En octobre 2025, Next.js 16 passe du cache implicite au cache explicite.
Avant, Next.js mettait beaucoup de choses en cache tout seul — pratique en surface, flou en debug : quoi, combien de temps, comment purger ? Données à jour côté serveur, page ancienne : souvent le cache.
Maintenant vous déclarez quoi mettre en cache et pour combien de temps. Un peu plus de travail, mais tout est visible et maîtrisable.
Trois axes d’optimisation
- Cache : ne pas refaire ce qui est déjà fait
- Streaming : envoyer au fil de l’eau, pas attendre la fin
- Edge : rapprocher le compute des utilisateurs
On détaille chaque axe.
Stratégies de cache : le bon choix change tout
Quatre mécanismes dans Next.js : Request Memoization, Data Cache, Full Route Cache, Router Cache. Au début, ça impressionne.
Pour les API Routes, retenez surtout Data Cache — résultats DB ou réponses API externes.
Scénario 1 : données statiques
Config site, liste de catégories : cache d’une heure ou plus.
// app/api/categories/route.js
export async function GET() {
const data = await fetch('https://api.example.com/categories', {
next: { revalidate: 3600 } // cache 1 heure
})
return Response.json(await data.json())
}
revalidate: 3600 = 1 h de cache, puis rafraîchissement.
Scénario 2 : données utilisateur
Profil personnel : peu de changements, mais pas de données périmées trop longtemps. stale-while-revalidate :
// app/api/user/profile/route.js
export async function GET(request) {
const user = await getUserFromDB()
return new Response(JSON.stringify(user), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 's-maxage=60, stale-while-revalidate=300'
}
})
}
On sert d’abord le cache (même un peu vieux), tout en rafraîchissant en arrière-plan. Latence faible, données pas trop anciennes.
s-maxage=60 : frais 60 s. stale-while-revalidate=300 : 300 s de grâce avec revalidation async.
Scénario 3 : temps réel sans cache
Cours boursiers, chat : pas de cache, ou WebSocket / Server-Sent Events.
export async function GET() {
const price = await getStockPrice()
return new Response(JSON.stringify(price), {
headers: {
'Cache-Control': 'no-store' // pas de cache
}
})
}
Invalidation : après une mise à jour ?
Profil modifié, cache encore ancien ? Invalidation manuelle.
revalidateTag et revalidatePath :
// app/api/user/update/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const data = await request.json()
await updateUserProfile(data)
// purger le cache utilisateur
revalidateTag('user-profile')
return Response.json({ success: true })
}
Côté lecture, taguer le cache :
export async function GET() {
const data = await fetch('db-api/user', {
next: {
revalidate: 3600,
tags: ['user-profile'] // tag
}
})
return Response.json(await data.json())
}
Après mise à jour, le prochain GET est frais.
Pièges fréquents
Sur-cache. Statut de commande en cache 1 h : paiement validé, UI figée. Durée = fonction du métier, pas « le plus long possible ».
Pas de warm-up. Premier hit lent (cache vide). Après déploiement, appeler une fois les routes chaudes.
Clé de cache incorrecte. Données de l’utilisateur A servies à B. Inclure user ID (et équivalents) dans la clé.
Réponses en streaming : fini les gros transferts bloquants
Le cache évite le recalcul ; parfois le calcul ou le volume reste lourd. Là, le streaming aide.
C’est quoi ?
Réponse classique : le restaurant sert tous les plats en même temps — on attend le plus lent.
Streaming : plat par plat. Durée totale proche, mais on mange pendant que la suite arrive.
Côté utilisateur : plus « écran blanc 3 s », mais « 500 ms et les premières lignes, je scroll pendant le reste ».
Quand l’utiliser ?
- Longues listes : produits, articles, recherche
- Contenu IA : effet machine à écrire = streaming
- Gros exports : Excel, PDF
- Logs temps réel : build, progression de tâche
Gros volume ou calcul long → streaming à envisager.
Implémentation Next.js
ReadableStream :
// app/api/posts/stream/route.js
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// lots successifs
for (let page = 0; page < 5; page++) {
// 20 entrées par lot
const posts = await fetchPostsFromDB({ page, limit: 20 })
// envoi du lot
const chunk = JSON.stringify(posts) + '\n'
controller.enqueue(encoder.encode(chunk))
// délai simulé
await new Promise(r => setTimeout(r, 100))
}
// fin
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked'
}
})
}
En bref :
- Créer
ReadableStream - Dans
start, récupérer par lots controller.enqueue()par lotcontroller.close()à la fin
Côté client
async function fetchStreamData() {
const response = await fetch('/api/posts/stream')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let allPosts = []
while (true) {
const { done, value } = await reader.read()
if (done) {
console.log('Réception terminée')
break
}
// décodage
const chunk = decoder.decode(value)
// JSON ligne par ligne
const posts = JSON.parse(chunk)
allPosts = [...allPosts, ...posts]
// MAJ UI en direct
updatePostList(allPosts)
}
}
La liste se remplit progressivement, pas d’écran blanc prolongé.
Mesures avant / après
Après streaming sur la liste du blog :
Le temps total gagne ~1,3 s, mais la perception est bien plus forte : interaction à 500 ms, le reste se lit pendant le chargement.
Astuce
Gros volumes : virtual scrolling. Ne rendre que le visible. 1000 lignes reçues, DOM léger. React : react-window ou react-virtualized ; Vue : vue-virtual-scroller.
Edge Functions : l’API au plus près des utilisateurs
Cache et streaming = logiciel. Autre levier : rapprocher le serveur.
La distance physique
La latence réseau vient surtout de la distance. Pékin ↔ côte ouest US : RTT ≥ 200 ms, loi de la physique.
Avant : un datacenter fixe (ex. Pékin). Rapide localement, lent ailleurs.
Edge : dizaines ou centaines de points ; routage vers le plus proche. Souvent < 50 ms.
Edge Runtime vs Node.js Runtime
Par défaut, les API Routes tournent en Node.js Runtime : fs, crypto, DB, etc.
Edge Runtime : moteur V8 (comme Chrome), pas Node complet. Démarrage 0-5 ms ; API Node limitées.
| Caractéristique | Node.js Runtime | Edge Runtime |
|---|---|---|
| Démarrage | 100-500 ms | 0-5 ms |
| API disponibles | Node.js complet | Web standard surtout |
| Cas d’usage | Logique lourde, DB | Auth légère, proxy, géo |
| Latence mondiale | Selon région de déploiement | Global <50 ms |
| Mémoire | Plus élevée | 128 Mo |
Cas d’usage Edge
Pas tout migrer. Exemples :
Auth
JWT, clé API : valider en edge, rejeter tôt.
// app/api/auth/route.js
export const runtime = 'edge'
export async function GET(request) {
const token = request.headers.get('authorization')
if (!token) {
return new Response('Unauthorized', { status: 401 })
}
// vérification (ex. jose, compatible Edge)
const isValid = await verifyToken(token)
if (!isValid) {
return new Response('Invalid token', { status: 401 })
}
return Response.json({ user: 'authenticated' })
}
Personnalisation géographique
Langue, devise, recommandations selon l’IP.
export const runtime = 'edge'
export async function GET(request) {
// géo injectée (ex. Vercel)
const country = request.geo?.country || 'US'
const city = request.geo?.city || 'Unknown'
const content = getLocalizedContent(country)
return Response.json({
country,
city,
content,
currency: country === 'CN' ? 'CNY' : 'USD'
})
}
Sans DB, traitement edge immédiat.
Proxy / agrégation API
Plusieurs APIs externes : un seul appel client, parallèle côté edge.
export const runtime = 'edge'
export async function GET(request) {
const [weather, news] = await Promise.all([
fetch('https://api.weather.com/...'),
fetch('https://api.news.com/...')
])
return Response.json({
weather: await weather.json(),
news: await news.json()
})
}
A/B test
Variante choisie en edge, sans toucher l’app principale.
export const runtime = 'edge'
export async function GET(request) {
const userId = request.headers.get('x-user-id')
const variant = parseInt(userId) % 2 === 0 ? 'A' : 'B'
const content = variant === 'A' ? getContentA() : getContentB()
return Response.json({ variant, content })
}
Limites Edge
Pas d’API Node exclusives — fs, path, child_process : erreur en Edge.
Connexions DB — pg, mysql2 (module net) : préférer HTTP, ex. Prisma Data Proxy, PlanetScale, Supabase, Redis HTTP.
RAM et durée — souvent 128 Mo et 30 s max : pas pour calcul lourd.
Approche hybride
- Edge : auth, géo, proxy simple
- Node.js : métier, DB, fichiers
Edge filtre et route ; le lourd reste au centre.
Benchmarks
D’après une étude Medium :
- Vercel Edge Functions : ~48,3 ms en moyenne
- Cloudflare Workers (custom) : ~36,37 ms
- API Node.js (une région) : 200-500 ms
Edge aide, surtout si l’audience est mondiale. Audience 100 % domestique : un serveur local peut gagner.
Cas pratique : liste d’articles du blog
Les trois techniques sur l’API lente du début.
Avant optimisation
// app/api/posts/route.js
export async function GET() {
// problème 1 : DB à chaque fois, pas de cache
const posts = await db.post.findMany({
take: 100,
include: {
author: true, // problème 2 : N+1
tags: true
}
})
// problème 3 : corps complet, payload énorme
return Response.json(posts)
}
Chiffres :
- Temps : 2800 ms
- JSON : 2,3 Mo
- UX : écran blanc ~3 s
Étape 1 : requête DB
Corriger le N+1, champs utiles seulement :
export async function GET() {
const posts = await db.post.findMany({
take: 100,
select: {
id: true,
title: true,
summary: true, // résumé, pas le corps
createdAt: true,
author: {
select: { name: true, avatar: true }
}
}
})
return Response.json(posts)
}
Résultat : ~800 ms, JSON ~180 Ko.
Étape 2 : cache
Liste peu volatile : 5 minutes.
export async function GET() {
const posts = await db.post.findMany({
// ... idem
}, {
next: {
revalidate: 300, // 5 min
tags: ['posts']
}
})
return Response.json(posts)
}
À la publication, purger :
// app/api/posts/publish/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const newPost = await request.json()
await db.post.create({ data: newPost })
revalidateTag('posts')
return Response.json({ success: true })
}
Cache hit : ~50 ms, charge serveur -90 %.
Étape 3 : streaming
Premier accès (cache froid) : encore ~800 ms. Passage en flux :
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const batchSize = 20
for (let page = 0; page < 5; page++) {
const posts = await db.post.findMany({
skip: page * batchSize,
take: batchSize,
select: { /* idem */ }
})
const chunk = JSON.stringify(posts) + '\n'
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'application/x-ndjson', // Newline Delimited JSON
'Cache-Control': 's-maxage=300, stale-while-revalidate=600'
}
})
}
Premier lot ~300 ms, navigation pendant le reste ; total ~800 ms mais peu ressenti.
Étape 4 : auth en Edge (optionnel)
// app/api/posts/route.js (couche Edge)
export const runtime = 'edge'
export async function GET(request) {
const token = request.headers.get('authorization')
if (!token) {
return new Response('Unauthorized', { status: 401 })
}
return fetch(`${process.env.API_BASE_URL}/posts/internal`, {
headers: { authorization: token }
})
}
Requêtes invalides stoppées en edge.
Tableau comparatif
| Indicateur | Avant | Après | Gain |
|---|---|---|---|
| Premier accès | 2800 ms | 300 ms (1er lot) | 89 % ↓ |
| Cache hit | - | 50 ms | 98 % ↓ |
| Taille JSON | 2,3 Mo | 180 Ko | 92 % ↓ |
| Temps interactif | 2800 ms | 300 ms | 89 % ↓ |
| Charge serveur | 100 % | 10 % | 90 % ↓ |
Fin de la blague « site d’une autre époque ».
Monitoring et amélioration continue
L’optimisation ne s’arrête pas au déploiement : mesurer pour ajuster.
Indicateurs clés
-
Distribution des temps (P50, P95, P99)
- P50 : médiane
- P95 : 95 % des utilisateurs
- P99 : cas extrêmes / anomalies
-
Taux de hit cache
- <70 % : stratégie à revoir
- >95 % : peut-être trop long, données périmées
-
Taux d’erreur
- Ne doit pas monter après optim
- Streaming : échec en cours de flux à surveiller
-
Répartition géographique
- Écart de latence par région → besoin Edge ou non
Outils
Vercel Analytics : sur Vercel, perfs par route.
Next.js Instrumentation API (2026) : points de mesure dans le code :
// instrumentation.js
export function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
require('./monitoring')
}
}
// monitoring.js
export function onRequestEnd(info) {
console.log(`API ${info.url} took ${info.duration}ms`)
sendToMonitoring({
url: info.url,
duration: info.duration,
status: info.status
})
}
Logs maison :
export async function GET() {
const start = Date.now()
const data = await fetchData()
const duration = Date.now() - start
console.log(`API /posts took ${duration}ms`)
return Response.json(data)
}
Bonnes pratiques
- Revoir le cache quand le métier change
- A/B test si vous hésitez entre deux approches
- Décider sur les données, pas au feeling
L’optimisation des perfs est un processus, pas un one-shot.
Synthèse
Cache : statique = long ; utilisateur = stale-while-revalidate ; temps réel = pas de cache. Invalider après mise à jour.
Streaming : gros volume ou calcul lent — montrer tôt, éviter l’écran blanc. Virtual scroll côté client en bonus.
Edge Functions : auth, géo, proxy léger. Pas pour la logique lourde ; combiner avec Node.js Runtime.
Commencez par la route la plus lente, appliquez les trois leviers, mesurez, ajustez. Pas besoin de la perfection du premier coup.
Ma liste d’articles : 3 s → 300 ms, différence visible pour les testeurs. Choisissez une API lente et attaquez-vous aujourd’hui. Questions en commentaires — on progresse ensemble.
FAQ
Quand le cache API Next.js expire-t-il ?
Le streaming convient-il à toutes les routes ?
Quelles limites pour Edge Functions ?
Comment choisir une stratégie de cache ?
Comment valider l'effet des optimisations ?
11 min de lecture · Publié le: 5 janv. 2026 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Guide complet des API Routes Next.js : Route Handlers et bonnes pratiques
Guide complet des API Routes Next.js : création des Route Handlers, traitement des requêtes, gestion d'erreurs et format de réponse pour développer vos endpoints backend sereinement.
Partie 18 sur 51
Suivant
Authentification et sécurité API Next.js : guide complet du JWT au rate limiting
Guide pratique complet pour sécuriser vos API Next.js : authentification JWT, configuration CORS, rate limiting et validation des entrées, avec méthodes de prévention des dernières vulnérabilités.
Partie 20 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire