Migration Vercel vers Railway : 2000 pages perdent l'Edge ISR, le TTFB explose
Jeudi 14h. L'équipe infra d'une marketplace B2B française — 2 100 pages catalogue, 380K sessions organiques par mois — finalise la migration de Vercel vers Railway. Motivation : réduire la facture d'hébergement de 40 %. Le déploiement passe. Les tests Cypress sont verts. Le DNS bascule à 16h. Vendredi soir, tout semble normal. Lundi matin, le rapport CrUX tombe. Le TTFB médian est passé de 340 ms à 1,4 seconde. Sur mobile, le LCP dépasse 4 secondes sur 73 % des pages produit.
Lundi 9h12 — Le rapport CrUX qui ne ment pas
C'est le lead SEO qui tire la sonnette. Il consulte le rapport PageSpeed Insights sur une page catégorie clé. Le score Performance mobile est tombé de 82 à 41. Le TTFB est rouge. Le LCP aussi. Le CLS est resté stable — ce n'est donc pas un problème de layout.
Il ouvre Search Console. Pas encore de signal d'alerte côté indexation — trop tôt. Mais le rapport Core Web Vitals montre déjà une migration de URLs du bucket "Bon" vers "À améliorer" sur les données de terrain.
À 9h35, il envoie un message au CTO : "On a un problème de perf massif depuis la migration. Le TTFB a triplé au minimum."
Le CTO répond vite : "Impossible, le déploiement Railway est identique. Même image Docker, même région eu-west."
Première hypothèse — la base de données. L'équipe vérifie les temps de réponse Postgres sur Railway. Le P95 est à 18 ms. Rien d'anormal. Les requêtes Prisma sont profilées : aucune régression.
Deuxième hypothèse — le CDN. Sur Vercel, le CDN edge était intégré. Sur Railway, l'équipe a configuré Cloudflare en proxy. Mais les headers cf-cache-status montrent des HIT sur les assets statiques. Le problème n'est pas là.
À 10h20, le développeur frontend senior tape une commande qui change tout :
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\nTotal: %{time_total}s\nHTTP: %{http_code}\n" \
-H "User-Agent: Googlebot" \
https://marketplace.example.com/produit/cable-hdmi-4k-2m
Résultat :
TTFB: 1.387s
Total: 1.412s
HTTP: 200
Même test sur le même slug, mais via le cache Wayback de la version Vercel d'il y a une semaine : TTFB de 87 ms. Le ratio est de ×16, pas ×4. Le ×4 est la médiane globale. Les pages dynamiques individuelles sont bien pires.
C'est à ce moment que l'équipe comprend : le problème n'est pas un ralentissement réseau. C'est l'absence totale de cache ISR. Chaque requête déclenche un rendu SSR complet.
Le bug : quand l'Edge ISR disparaît sans laisser de trace
Pour comprendre ce qui s'est passé, il faut revenir à l'architecture en place sur Vercel.
La configuration Vercel d'origine
Le site tournait sur Next.js 14.2 avec l'App Router. Les pages produit utilisaient generateStaticParams pour le pré-rendu, combiné à revalidate pour l'ISR :
// app/produit/[slug]/page.tsx — version Vercel
export const revalidate = 3600; // ISR : revalidation toutes les heures
export async function generateStaticParams() {
const products = await prisma.product.findMany({
select: { slug: true },
});
return products.map((p) => ({ slug: p.slug }));
}
export default async function ProductPage({ params }: { params: { slug: string } }) {
const product = await prisma.product.findUnique({
where: { slug: params.slug },
include: { category: true, specs: true },
});
if (!product) notFound();
return (
<>
<ProductJsonLd product={product} />
<ProductDetail product={product} />
</>
);
}
Sur Vercel, ce code produisait un comportement précis :
- Au build,
generateStaticParamspré-rendait les 2 100 pages en HTML statique. - Les pages étaient servies depuis l'edge CDN de Vercel, avec un TTFB de 40 à 90 ms.
- Après 3 600 secondes, une requête entrante déclenchait une revalidation en arrière-plan. L'ancienne version restait servie pendant la régénération — c'est le principe du stale-while-revalidate.
- Les Edge Functions de Vercel géraient cette logique nativement, sans configuration supplémentaire.
Les headers de réponse sur Vercel ressemblaient à ceci :
x-vercel-cache: HIT
cache-control: s-maxage=3600, stale-while-revalidate
x-nextjs-cache: HIT
Ce qui se passe sur Railway
Railway exécute Next.js en mode standalone via next start. L'image Docker contient le serveur Node.js, point final. Il n'y a pas d'edge CDN intégré. Pas de couche de cache ISR persistante entre le serveur et le client.
Le next.config.js de l'équipe :
// next.config.js — version Railway
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.marketplace.example.com' },
],
},
};
module.exports = nextConfig;
Avec output: 'standalone', Next.js génère un serveur Node minimal. Mais ce serveur n'a aucun mécanisme de cache ISR intégré. Le répertoire .next/cache qui stocke les pages ISR sur Vercel n'est pas persisté entre les redéploiements sur Railway. Pire : même à l'intérieur d'un même déploiement, le cache ISR in-memory est perdu à chaque restart du container.
Résultat : chaque requête sur /produit/cable-hdmi-4k-2m déclenche un rendu SSR complet. Appel Prisma. Rendu React. Sérialisation HTML. À chaque fois. Pour chaque visiteur. Pour chaque crawl Googlebot.
Ce que voit le développeur vs ce que voit Googlebot
Le développeur teste dans Chrome. La page se charge en 600 ms environ — le navigateur est à Paris, le serveur Railway aussi, et le navigateur a du cache local sur les fonts et le CSS.
Googlebot n'a pas ce luxe. Il crawle depuis des IPs américaines (principalement). Il n'a pas de cache local. Il exécute chaque requête à froid. Et il reçoit un TTFB de 1,2 à 1,8 seconde selon la complexité de la page.
Pour vérifier, l'équipe utilise Chrome DevTools en throttling "Slow 3G" et en mode Incognito — ça se rapproche de l'expérience réelle, mais pas complètement. Le vrai diagnostic vient de l'outil d'inspection d'URL de Search Console, qui affiche le HTML rendu tel que Googlebot le reçoit, avec le temps de chargement.
Sur 2 100 pages produit, Screaming Frog en mode rendu JavaScript révèle des temps de réponse moyens de 1 340 ms, contre 280 ms relevés une semaine avant la migration sur un crawl archivé.
Pourquoi les tests CI n'ont rien détecté
L'équipe avait des tests Cypress end-to-end. Mais ces tests vérifient le contenu rendu, pas la performance de rendu. Un cy.get('[data-testid="product-title"]').should('exist') passe en 1,4 seconde comme en 90 ms.
Les tests Lighthouse CI étaient configurés pour tourner sur un environnement de staging Railway. Le staging affichait des scores corrects — parce qu'il n'avait que 50 produits en base, pas 2 100. Le SSR de 50 pages sans cache ne produit pas la même charge qu'un SSR de 2 100 pages sous trafic réel.
Aucun test ne comparait le TTFB avant/après migration. Aucun test ne vérifiait la présence des headers de cache ISR. C'est un angle mort classique des pipelines CI pour le SEO technique — un problème détaillé dans cet article sur le stress testing des environnements de staging.
Le parallèle avec d'autres migrations est frappant. Comme lors d'un passage de Cloudflare vers Bunny CDN, c'est la couche d'infrastructure "invisible" qui casse silencieusement — pas le code applicatif.
Le fix : reconstruire une couche de cache ISR sans Vercel
L'équipe a exploré trois options. La première — revenir sur Vercel — a été écartée pour des raisons budgétaires. La deuxième — implémenter un cache ISR custom avec Redis — a été retenue. La troisième — passer à un export statique complet — était incompatible avec les besoins de personnalisation en temps réel.
Le patch : cache ISR via Redis sur Railway
Railway supporte nativement les instances Redis. L'équipe a déployé un plugin Redis et configuré un cache handler custom pour Next.js, disponible depuis Next.js 14.1.
Étape 1 — Le cache handler custom :
// lib/cache-handler.ts
import { CacheHandler } from 'next/dist/server/lib/incremental-cache';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const CACHE_PREFIX = 'next-isr:';
export default class RedisCacheHandler extends CacheHandler {
constructor(options: any) {
super(options);
}
async get(key: string) {
const data = await redis.get(`${CACHE_PREFIX}${key}`);
if (!data) return null;
const parsed = JSON.parse(data);
return {
value: parsed.value,
lastModified: parsed.lastModified,
};
}
async set(key: string, data: any, ctx: { revalidate?: number | false }) {
const payload = JSON.stringify({
value: data,
lastModified: Date.now(),
});
if (ctx.revalidate && typeof ctx.revalidate === 'number') {
// TTL = revalidate + 60s de grâce pour le stale-while-revalidate
await redis.setex(`${CACHE_PREFIX}${key}`, ctx.revalidate + 60, payload);
} else {
await redis.set(`${CACHE_PREFIX}${key}`, payload);
}
}
async revalidateTag(tag: string) {
const keys = await redis.keys(`${CACHE_PREFIX}*`);
// Implémentation simplifiée — en prod, utiliser un index par tag
for (const key of keys) {
await redis.del(key);
}
}
}
Étape 2 — La configuration Next.js mise à jour :
// next.config.js — version Railway avec cache handler
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
cacheHandler: require.resolve('./lib/cache-handler.ts'),
cacheMaxMemorySize: 0, // Désactiver le cache in-memory, tout passe par Redis
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.marketplace.example.com' },
],
},
};
module.exports = nextConfig;
Étape 3 — Ajout de headers cache-control via le middleware Cloudflare pour que le CDN en amont serve du stale-while-revalidate :
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const response = NextResponse.next();
if (request.nextUrl.pathname.startsWith('/produit/')) {
response.headers.set(
'Cache-Control',
's-maxage=3600, stale-while-revalidate=86400'
);
response.headers.set('CDN-Cache-Control', 's-maxage=3600');
}
return response;
}
export const config = {
matcher: '/produit/:slug*',
};
Le redéploiement
Le déploiement a eu lieu mercredi à 11h. L'équipe a ensuite déclenché un crawl ciblé via curl sur les 200 pages les plus trafiquées pour chauffer le cache Redis :
# warm-cache.sh — pré-chauffage des pages critiques
cat top-200-slugs.txt | while read slug; do
curl -s -o /dev/null -w "%{url_effective} → TTFB: %{time_starttransfer}s\n" \
"https://marketplace.example.com/produit/${slug}"
sleep 0.2
done
Résultat immédiat après warm-up : TTFB médian retombé à 120 ms sur les pages cachées. Les pages non-cachées (premières requêtes) restaient à 800-1 100 ms — le coût du SSR initial — mais la seconde requête tombait à 45-90 ms grâce au cache Redis.
La récupération
Les Core Web Vitals CrUX agrègent les données sur 28 jours glissants. L'équipe a donc dû attendre environ 3 semaines pour voir le rapport repasser au vert. Voici la chronologie :
- J+0 (mercredi) : déploiement du fix. TTFB médian repasse sous 200 ms.
- J+3 : les données de lab (Lighthouse, PageSpeed Insights en mode "simulé") montrent des scores revenus entre 78 et 85.
- J+7 : Search Console commence à reclasser des URLs du bucket "À améliorer" vers "Bon".
- J+14 : 68 % des URLs produit sont de retour dans le bucket "Bon".
- J+22 : 94 % des URLs produit sont en "Bon". Le TTFB P75 sur CrUX est à 180 ms.
Côté trafic organique, la chute a été mesurable mais contenue. GA4 montre une baisse de 12 % des sessions organiques sur les pages produit pendant les 10 premiers jours. Pas de déclassement brutal — Google n'a pas eu le temps de crawler massivement les pages lentes avant le fix. Si l'incident avait duré 6 semaines, le scénario aurait été bien pire, comme dans cette migration Nuxt 2 vers Nuxt 3 où les pages en layout cassé ont mis deux mois à récupérer.
La migration aurait aussi pu causer un problème de canonicals pointant vers un staging, si les URLs internes avaient référencé l'ancien domaine Vercel. L'équipe a vérifié — ce n'était pas le cas ici, mais c'est un piège classique.
Ce qu'on en retient
Migrer un site Next.js hors de Vercel, c'est migrer hors de l'infrastructure invisible qui fait fonctionner l'ISR, l'edge caching et le stale-while-revalidate. Le code applicatif ne change pas. Le comportement en production change radicalement.
Trois leçons opérationnelles :
-
Tester le TTFB, pas seulement le contenu. Un test CI doit inclure une assertion sur le temps de réponse serveur.
expect(ttfb).toBeLessThan(500)— c'est une ligne qui aurait tout changé. -
Vérifier les headers de cache après chaque migration d'infra. Un
curl -Isur 10 URLs critiques prend 30 secondes et révèle l'absence de cache. -
Monitorer le TTFB en continu, pas seulement au déploiement. Les données CrUX arrivent avec 28 jours de retard. Un monitoring continu type Seogard détecte une explosion du TTFB en quelques minutes — pas trois semaines après, quand le rapport CrUX vire au rouge et que le trafic a déjà décroché.
L'Edge ISR n'est pas une feature bonus. C'est le socle de performance sur lequel repose toute la stratégie SEO d'un site à 2 000 pages. Le retirer sans le remplacer, c'est passer d'un site statique ultra-rapide à un site SSR des années 2010 — et Google le voit avant l'équipe.