Changer le thème

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

Easton editorial illustration: state-management shelf

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

  1. Cache : ne pas refaire ce qui est déjà fait
  2. Streaming : envoyer au fil de l’eau, pas attendre la fin
  3. 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.

500 ms → 50 ms
Temps de réponse -90 %
Liste catégories en cache : la plupart des requêtes ne touchent plus la base

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 ?

  1. Longues listes : produits, articles, recherche
  2. Contenu IA : effet machine à écrire = streaming
  3. Gros exports : Excel, PDF
  4. 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 :

  1. Créer ReadableStream
  2. Dans start, récupérer par lots
  3. controller.enqueue() par lot
  4. controller.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 :

2800 ms → 500 ms
Affichage premier lot
Avant : 2800 ms puis tout ; après : 20 articles en 500 ms, navigation immédiate

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éristiqueNode.js RuntimeEdge Runtime
Démarrage100-500 ms0-5 ms
API disponiblesNode.js completWeb standard surtout
Cas d’usageLogique lourde, DBAuth légère, proxy, géo
Latence mondialeSelon région de déploiementGlobal <50 ms
MémoirePlus élevée128 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' })
}
200 ms → 20 ms
Latence auth -90 %
Token validé en edge ; requêtes invalides n’atteignent pas le cœur

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 exclusivesfs, path, child_process : erreur en Edge.

Connexions DBpg, 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

IndicateurAvantAprèsGain
Premier accès2800 ms300 ms (1er lot)89 % ↓
Cache hit-50 ms98 % ↓
Taille JSON2,3 Mo180 Ko92 % ↓
Temps interactif2800 ms300 ms89 % ↓
Charge serveur100 %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

  1. Distribution des temps (P50, P95, P99)

    • P50 : médiane
    • P95 : 95 % des utilisateurs
    • P99 : cas extrêmes / anomalies
  2. Taux de hit cache

    • <70 % : stratégie à revoir
    • >95 % : peut-être trop long, données périmées
  3. Taux d’erreur

    • Ne doit pas monter après optim
    • Streaming : échec en cours de flux à surveiller
  4. 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

  1. Revoir le cache quand le métier change
  2. A/B test si vous hésitez entre deux approches
  3. 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 ?
Trois cas : 1) expiration temporelle (revalidate atteint), 2) invalidation manuelle (revalidateTag ou revalidatePath), 3) rafraîchissement forcé (Ctrl+Shift+R). Les deux premiers sont les plus courants ; adaptez revalidate à la fréquence de mise à jour des données.
Le streaming convient-il à toutes les routes ?
Non. Idéal pour gros volumes (longues listes) ou calcul long (génération IA). Petits payloads et calcul rapide : réponse classique suffit. Règle : temps de réponse > 1 s ou JSON > 500 Ko → envisager le streaming.
Quelles limites pour Edge Functions ?
Trois points : 1) pas d'API Node.js exclusives (fs, child_process), 2) base de données via HTTP (ex. Prisma Data Proxy), 3) 128 Mo RAM et 30 s max. Auth et proxy légers oui ; logique métier lourde reste sur Node.js Runtime.
Comment choisir une stratégie de cache ?
Selon la fraîcheur : statique (config, catégories) cache long (1 h+), utilisateur (profil) stale-while-revalidate (60 s frais + 300 s revalidation), temps réel (cours boursier) pas de cache ou WebSocket. Plus le cache est long, plus c'est performant — mais plus les données peuvent être périmées.
Comment valider l'effet des optimisations ?
Quatre indicateurs : 1) temps de réponse (P50, P95, P99), 2) taux de hit cache (cible 70-95 %), 3) taux d'erreur (ne doit pas monter), 4) latence par région. Vercel Analytics, Next.js Instrumentation API ou logs custom ; faites un A/B avant/après.

11 min de lecture · Publié le: 5 janv. 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog