optimisation core web vitals 9 min

Paradoxe BDR : l'optimisation de la base de données plombe le LCP

Des index parfaits, des requêtes en 2 ms et pourtant un LCP à 4 secondes. Ce paradoxe des applications back-end-driven est plus fréquent qu'on ne le croit. On décortique le piège.

Par Julien Morel
Partager

On a diagnostiqué un site e-commerce dont chaque fiche produit renvoyait un TTFB de 180 ms, mais un LCP à 4,2 secondes. L’équipe technique jurait que la base de données était optimisée : index couvrants, plans d’exécution validés, temps de réponse moyen sous 10 ms. Pourtant, l’utilisateur attendait. C’est le paradoxe des apps BDR (back-end-driven rendering) qu’on observe chez des dizaines de clients : l’optimisation des requêtes prise isolément crée des goulots séquentiels qui repoussent le LCP bien au-delà de ce que laisserait croire le TTFB. Cet article démonte le mécanisme et te donne une méthode de mesure qui ne se limite pas au monitoring SQL.

Quand une base de données parfaite devient un problème

Une base de données avec des index parfaits et un cache bien configuré peut générer un faux sentiment de sécurité. Le TTFB reste bas parce que le serveur répond vite avec un en-tête HTTP 200 et un squelette HTML vide. Le LCP, lui, dépend de l’arrivée du contenu principal (image, bloc de texte), qui nécessite plusieurs allers-retours à la BDD. Si ces appels sont sérialisés dans une architecture MVC classique, chaque requête ajoute sa latence au moment où le navigateur peut enfin peindre le LCP. Un SQL à 2 ms n’y changera rien si tu as cinq appels en cascade.

L’enchaînement séquentiel que les DevTools ne montrent pas

Quand tu ouvres l’onglet Réseau des DevTools, tu vois la requête principale et les ressources statiques. Tu ne vois pas la séquence des appels SQL internes qui construisent le HTML côté serveur. Pourtant, c’est bien cet enchaînement qui peut faire basculer un LCP de 1,8 s à 4 s. Sur une application typée back-end-driven, le contrôleur attend le résultat d’une première requête (catégorie, prix, stock) pour lancer la deuxième (recommandations), puis la troisième (avis clients). Chaque étape est rapide individuellement, mais le délai cumulé atteint vite 600 ms à 1 200 ms avant que le moindre octet du contenu principal ne soit envoyé au client.

Le piège est amplifié par la persistance des connexions : le pool de connexions reste ouvert, donc le serveur d’applications ne libère pas le thread, et les appels suivants patientent. Résultat : un TTFB qui ne reflète que le temps d’envoi du premier chunk, mais un LCP qui explose parce que la portion visible du DOM dépend de la troisième requête. Les APM (Datadog, New Relic) te montreront des temps SQL excellents, mais ils ne corrèlent pas par défaut ces traces avec le moment où le navigateur déclenche le Largest Contentful Paint.

On a creusé ce comportement sur un back-end Node.js avec Sequelize. En loggant un simple performance.mark() avant chaque await, on a constaté que le LCP était retardé de 900 ms par rapport à une version où les trois requêtes étaient lancées en parallèle via Promise.all(). Pourtant, le code original paraissait propre : un async/await par opération, sans dépendance logique. C’est là que l’optimisation de la base de données trompe : comme chaque requête ne prend que 4 ms, le développeur ne ressent pas le besoin de paralléliser. La somme fait mal.

Une approche pragmatique consiste à tracer le « time to meaningful paint » côté serveur : injecter un Custom Mark dans la réponse HTTP pour mesurer l’instant où le dernier bout de HTML nécessaire au LCP est écrit dans le flux. Nous l’avons fait avec un simple header Server-Timing enrichi. Ce chiffre était le seul à prédire le LCP, bien mieux que le TTFB ou le temps moyen des requêtes. Pour approfondir les stratégies de mesure, consulte notre guide sur l’optimisation Core Web Vitals.

Cache de requêtes et LCP : le trade-off invisible

Beaucoup de frameworks type Laravel ou Django proposent un cache de requêtes SQL en RAM (Redis, Memcached). Sur le papier, le cache abaisse le temps de réponse de la BDD à moins de 1 ms. Pourtant, nous avons vu des cas où l’introduction d’un cache agressif a dégradé le LCP de 15 %. Pourquoi ? Le cache évite le round-trip SQL, mais le processus d’invalidation (effacement sélectif, régénération partielle) peut entraîner des points de blocage synchrones. Par exemple, si la page produit dépend d’une clé de cache pour le prix et que cette clé expire, le thread serveur se bloque le temps de relancer la requête SQL, recalculer, et stocker dans le cache. Ce temps de régénération s’ajoute au temps total avant envoi du contenu principal.

De plus, certains systèmes de cache de template stockent des fragments HTML. Quand un fragment est périmé, le serveur attend sa régénération avant d’envoyer la suite. L’effet est le même qu’une requête SQL lente, mais il est invisible dans les logs de base de données. La recommandation ici n’est pas d’abandonner le cache, mais d’adopter une stratégie de « stale-while-revalidate » au niveau applicatif : servir la version périmée immédiatement, et reconstruire le cache en arrière-plan pour les prochaines visites. Cela découple la régénération du temps de réponse perçu par l’utilisateur. Nous avons appliqué cette méthode sur un projet Next.js en edge rendering, et le LCP a gagné 400 ms sans toucher aux requêtes. Si tu veux automatiser la détection de ces goulots via des inspections de code, compare les approches Claude Code et Cursor.

Mesurer le vrai impact avec les Custom Timings

Les outils de monitoring classiques te donneront le TTFB, le FCP, le LCP. Ils ne te diront pas quel appel SQL précis a retardé le chargement du titre de la page. Pour ça, il faut instrumenter le serveur. Avec Node.js, on peut utiliser performance.now() à chaque étape de construction de la réponse et les injecter dans le Server-Timing header. Cela te donne une cascade de durées visibles directement dans l’onglet Timing des DevTools.

Voici un exemple minimal que nous avons mis en place sur une route Express :

app.get('/produit/:id', async (req, res) => {
  const t0 = performance.now();
  const infos = await getProductInfo(req.params.id);
  const t1 = performance.now();
  const related = await getRelatedProducts(req.params.id);
  const t2 = performance.now();
  const reviews = await getReviews(req.params.id);
  const t3 = performance.now();
  res.setHeader('Server-Timing', `sql_info;dur=${t1-t0}, sql_related;dur=${t2-t1}, sql_reviews;dur=${t3-t2}`);
  res.render('product', { infos, related, reviews });
});

On voit immédiatement que le rendu ne peut commencer qu’après les trois appels. Le cumul atteignait 520 ms sur le cas réel. En parallélisant les deux dernières requêtes (qui ne dépendaient pas l’une de l’autre), on a ramené ce cumul à 280 ms, sans modifier la base de données. Le LCP a suivi.

L’astuce consiste à ne jamais mesurer le SQL seul. Mesure le delta entre le moment où la requête arrive et celui où le dernier morceau de HTML nécessaire au LCP est écrit. Si ce delta est supérieur à la somme des temps SQL, c’est que ton séquencement est en cause. Côté front, une fois le HTML arrivé, l’hydratation du state peut générer un INP dégradé si la structure des données est trop verbeuse ; Zustand a montré des résultats solides en évitant les rendus en cascade.

Migrer sans casser le rendu : la stratégie du rendu progressif

Quand tu fais évoluer un back-end BDR vers une architecture plus moderne, ne commence pas par réécrire les requêtes. Identifie d’abord les dépendances strictes entre les données nécessaires au LCP. Souvent, le titre, le prix et l’image principale peuvent être récupérés en un seul appel SQL ou via une API unique. Désérialise le chargement en deux temps : un premier flux rapide pour le LCP, un second pour le contenu secondaire. Tu gagneras en LCP sans toucher au SGBD.

Questions fréquentes

Est-ce que les SSR modernes comme Next.js App Router éliminent ce problème ?
Non, car même avec React Server Components, les appels à la base de données restent séquentiels si on les await l’un après l’autre sans les grouper. La solution réside dans le code, pas dans le framework.

Peut-on utiliser un CDN pour contourner le problème ?
Un CDN cache la page complète, donc le LCP est dicté par le temps de chargement du HTML mis en cache, pas par les requêtes SQL. Mais pour les pages dynamiques avec des données variables, le CDN ne peut pas tout. Le piège demeure.

Le choix de l’ORM a-t-il un impact sur la latence cumulée ?
Certains ORM permettent de requêter les relations en une fois avec eager loading. Mal utilisé, le lazy loading de Sequelize ou TypeORM peut générer des centaines de micro-requêtes. L’impact sur le LCP est direct. Vérifiez les logs de requêtes.

Articles similaires

Julien Morel

Julien Morel

Ancien dev front React passé SEO technique après une migration e-commerce qui a fait perdre 60% du trafic organique à son employeur en une nuit (fichier robots.txt oublié en staging). Depuis, il écrit pour que ça n'arrive à personne d'autre et teste sur ses propres side-projects avant de publier quoi que ce soit.

Cet article est publie a titre informatif. Faites vos propres recherches avant toute decision.