Le réflexe classique en Next.js, c’est de “cacher” une API externe lente derrière une route /api. Sur le papier, c’est propre. En production, c’est souvent un piège : tu te retrouves avec un build qui part chercher des données (et consomme des minutes), ou avec un premier hit à 60 secondes, ou avec un revalidate qui ne te sauve pas quand tu crois qu’il te sauve.
Le point clé à accepter, un peu brutalement : si ton upstream met 60–70 secondes, tu ne l’optimises pas en le proxifiant. Tu dois changer le chemin critique. Servir quelque chose de rapide, et rafraîchir ailleurs.
Le faux confort : “je proxy l’API, et je mets un cache”
Une route handler Next.js qui fait fetch() vers une API lente, c’est un proxy. Un proxy ne crée pas de performance. Il crée une nouvelle surface de timeouts, de cold starts, de concurrence et de facturation.
Sur Vercel, tu as trois modes de douleur qui reviennent toujours. Et tu les reconnais vite quand tu as déjà subi une mise en prod un vendredi.
Sponsorisé par Le Scribouillard
Besoin de contenu optimisé SEO ?
Utilisez la meilleure plateforme française de création de contenu assistée par IA ! Et générez des articles pour moins de 1€ !
Les 3 modes qui font mal (et pourquoi tu les déclenches sans le vouloir)
1) Le fetch au build : tu payes en minutes, pas en millisecondes
Si une page (ou un segment) est rendu statiquement, Next peut exécuter des fetchs pendant le build pour produire le HTML. Si ton code fait un appel externe qui traîne, tu viens de convertir une latence utilisateur en durée de build. C’est discret au début, puis ça devient ingérable dès que tu déploies souvent.
Le plus vicieux, c’est que ça ressemble à une “optimisation” : la page est statique, donc rapide. Sauf que tu as déplacé l’attente ailleurs, et tu l’as rendue bloquante pour chaque déploiement.
2) Le fetch au 1er hit : ton site est “up”, mais inutilisable
Tu forces du dynamique (ou tu as une route dynamique), donc le build va vite. Super. Sauf que la première requête en prod se prend la génération complète. Avec une API à 60 secondes, tu viens de créer un cold-start fonctionnel : le site répond, mais l’utilisateur attend (ou time out), et les crawlers aussi.
Et si plusieurs utilisateurs arrivent en même temps, tu crées un effet “dogpile” : tout le monde déclenche la même régénération lente, en parallèle, au lieu de mutualiser.
3) Le revalidate qui bloque : tu pensais servir du stale, tu sers de l’attente
Beaucoup imaginent que revalidate = “stale-while-revalidate automatique”. Selon le type de rendu (page vs route), le runtime, et la présence ou non d’une entrée déjà en cache, tu peux te retrouver avec une régénération qui bloque la réponse au lieu de servir l’ancien résultat. Dans la vraie vie, le symptôme est simple : tu as bien un cache, mais au moment où tu en as besoin, tu renvoies quand même 60 secondes de TTFB.
La morale est claire : ISR/revalidate est utile, mais ce n’est pas un contrat produit. Le produit, lui, a besoin d’un comportement stable : “je renvoie vite, même si ce n’est pas parfaitement frais”.
Le pattern qui tient : servir du stale, rafraîchir en tâche de fond
Ce que tu veux vraiment, c’est un stale-while-revalidate explicite. Pas “j’espère que le framework va le faire comme je l’imagine”, mais un comportement que tu contrôles.
Le principe est basique : tu stockes le dernier résultat “OK” quelque part (KV/Redis/DB), tu le sers immédiatement, et tu déclenches un rafraîchissement asynchrone quand il est trop vieux. Si le refresh échoue, tu continues de servir le dernier bon résultat, en journalisant l’erreur. C’est moche intellectuellement (on sert du vieux), mais c’est propre en UX.
Sur Vercel, un store type Vercel KV (ou Upstash Redis) fait très bien le job. La Data Cache de Next peut aider, mais dès que tu veux contrôler finement la fraîcheur, les fallbacks et l’anti-dogpile, un cache applicatif explicite te simplifie la vie.
Exemple concret : route handler avec cache + refresh en fond
Ci-dessous, une approche simple : on lit un cache, on renvoie vite, puis on rafraîchit après coup. Le point important n’est pas “le code exact”, c’est l’intention : ne jamais attendre l’API lente sur le chemin critique si tu as déjà quelque chose.
import { NextResponse } from "next/server";
import { after } from "next/server";
import { kv } from "@vercel/kv";
const KEY = "external:payload:v1";
const TTL_SECONDS = 300; // fraîcheur cible (produit)
const MAX_STALE_SECONDS = 3600; // au-delà, on préfère dégrader autrement
async function fetchUpstream() {
const res = await fetch("https://api.externe.exemple/data", {
// Important: ne pas laisser Next "geler" ça au build
cache: "no-store",
});
if (!res.ok) throw new Error(`Upstream ${res.status}`);
return res.json();
}
export async function GET() {
const cached = await kv.get<{ value: unknown; fetchedAt: number }>(KEY);
const now = Date.now();
if (cached?.value) {
const ageSeconds = Math.floor((now - cached.fetchedAt) / 1000);
// On sert tout de suite.
const response = NextResponse.json({
data: cached.value,
cache: { ageSeconds, isStale: ageSeconds > TTL_SECONDS },
});
// Et si c'est "trop vieux", on rafraîchit après la réponse.
if (ageSeconds > TTL_SECONDS && ageSeconds < MAX_STALE_SECONDS) {
after(async () => {
// Idéalement, ajoute un lock anti-dogpile ici.
const value = await fetchUpstream();
await kv.set(KEY, { value, fetchedAt: Date.now() });
});
}
return response;
}
// Cache miss: là, tu as un choix produit à faire.
// Soit tu attends (et tu assumes la latence), soit tu dégrades.
const value = await fetchUpstream();
await kv.set(KEY, { value, fetchedAt: Date.now() });
return NextResponse.json({ data: value, cache: { ageSeconds: 0, isStale: false } });
}Deux détails qui comptent vraiment.
D’abord, cache: "no-store" sur le fetch upstream. Ce n’est pas une question de “performance”, c’est une question de contrôle. Tu ne veux pas que Next décide de rendre ça statique ou de le rejouer au build selon le contexte. Tu veux que ton cache à toi soit la source de vérité.
Ensuite, le after(). Sur des runtimes serverless, “lancer une promesse sans await” n’est pas un plan fiable. Tu veux une primitive prévue pour exécuter du travail après la réponse (ou un mécanisme équivalent côté Edge avec waitUntil). Sinon, tu vas avoir des refresh aléatoires qui disparaissent quand la fonction se fait couper.
Le point qui évite les nuits blanches : l’anti-dogpile (lock léger)
Quand le cache devient stale, tu ne veux pas que 200 requêtes déclenchent 200 refreshs. Même si l’API externe n’était pas lente, c’est juste du gaspillage. Et avec une API lente, c’est un DDoS involontaire.
La version simple consiste à poser un verrou avec expiration dans ton KV avant de rafraîchir. Si le lock existe, tu ne refresh pas, tu sers le stale et tu laisses une seule requête faire le boulot. Tu peux le faire proprement avec un SETNX Redis (ou équivalent) et une TTL courte. Le verrou doit expirer tout seul, parce qu’un refresh peut échouer, et tu ne veux pas bloquer la régénération pendant une heure à cause d’un crash.
Sortir le long-running du chemin request : cron, webhook, queue
Si l’API met vraiment 60–70 secondes, même “au cache miss” ça pique. Et surtout, ça dépasse souvent les limites de durée d’exécution d’une fonction serverless selon ton plan et ton runtime. Dans ce cas, tu dois arrêter de faire semblant : tu ne peux pas baser ton UX sur une requête aussi longue.
Le pattern robuste, c’est de pré-calculer hors du trafic utilisateur. Typiquement, un cron Vercel qui tourne toutes les X minutes, récupère les données, met à jour ton KV/DB, et ton app ne fait que lire. Tu as une latence stable et un taux de cache hit proche de 100%. Et surtout, tu transforms un problème “perçu par l’utilisateur” en un problème d’intégration backend, donc gérable.
Si ton fournisseur peut pousser des updates, un webhook est encore mieux : tu rafraîchis quand ça change, pas “toutes les 5 minutes parce que”. Et si le refresh implique plusieurs étapes (ou des retries), une queue est souvent la bonne forme : tu acceptes l’événement, tu traites en asynchrone, tu stockes un résultat, tu exposes un état.
Le contrat produit : fraîcheur acceptable, fallback, et erreurs
On oublie trop souvent que “cacher une API lente” n’est pas un sujet de framework. C’est un sujet de contrat. Est-ce que l’utilisateur accepte des données qui ont 5 minutes ? 1 heure ? Est-ce que certaines parties de la page doivent être fraîches, et d’autres non ? Est-ce que le bon fallback est “afficher la dernière donnée connue”, “afficher vide”, ou “afficher une erreur claire” ?
Si tu n’écris pas ces décisions, tu finis avec un comportement incohérent. Le cache devient une loterie, et tu ne sais plus si tu dois optimiser le TTFB, la fraîcheur, ou la stabilité. Un contrat simple aide : “on sert du stale jusqu’à 1h si l’upstream est down”, ou “au-delà de 15 minutes, on affiche un bandeau ‘données en retard’”, ou “les admins ont un bouton refresh manuel”. Ce n’est pas du luxe. C’est ce qui évite de sur-ingénierie au mauvais endroit.
Éviter le piège du build : contrôler le statique vs dynamique
Si tu as une page qui ne doit jamais déclencher un fetch lent au build, dis-le explicitement. Dans l’App Router, tu peux forcer le rendu dynamique d’un segment, ou rendre ton fetch non-cacheable côté Next. L’objectif n’est pas “tout passer en dynamique”, c’est d’éviter que Next interprète ton code comme statique et fasse le travail au mauvais moment.
Le bon signal à traquer, c’est simple : “est-ce que mon déploiement dépend d’une ressource externe lente ou instable ?”. Si oui, ce n’est pas un build, c’est un pipeline d’intégration. Et ton pipeline doit être robuste aux pannes externes, pas bloqué dessus.
Mesurer sans se raconter d’histoires : TTFB, cache hit, latence perçue
Quand tu mets du cache, tu peux très vite te persuader que ça va mieux parce que “ça marche sur ma machine”. En prod, tu veux des chiffres.
Le premier, c’est le TTFB. Pas uniquement la durée totale. Si ton TTFB explose, c’est que tu attends encore quelque chose sur le chemin critique. Ensuite, tu veux un taux de cache hit (au moins un log qui dit “served from cache” vs “cache miss” vs “stale served + refresh”). Enfin, tu veux la latence côté utilisateur, la vraie, sur les routes qui comptent. Une UI qui rend tout de suite avec une donnée “un peu vieille” peut être largement meilleure qu’une UI parfaite qui arrive en 70 secondes.
Et oui, tu dois aussi regarder les erreurs upstream. Un cache n’est pas là pour cacher la poussière sous le tapis. Il est là pour stabiliser l’expérience. Si ton upstream est flaky, tu dois le savoir, et tu dois choisir comment ton produit réagit.
Mon avis (assez sec) sur l’anti-pattern “API proxy”
Une route Next.js qui “proxy” une API lente, sans cache applicatif explicite, sans contrat de fraîcheur, et sans mécanisme de refresh hors requête, c’est une dette. Ça peut passer en démo. En prod, tu vas payer en temps de build, en timeouts, en incidents, en UX qui clignote, et en débats stériles sur “ISR marche pas”.
Le bon move, c’est d’assumer que tu fais de la distribution. Tu sers une donnée versionnée, avec une fraîcheur maîtrisée, et tu sépares la lecture rapide du calcul lent. À partir de là, Next.js et Vercel redeviennent ce qu’ils sont : d’excellents outils de delivery. Pas une baguette magique pour une API à 70 secondes.