optimisation core web vitals 7 min

Export CSV PHP : l'erreur qui plombe ton TTFB sans que tu le voies

Quand ton script PHP d'export CSV fait exploser le TTFB et bouffe ta mémoire serveur, voici comment le corriger sans refondre tout le backend.

Par Julien Morel
Partager

Jeudi soir, 22 heures. Un client e-commerce nous envoie un screenshot de son monitoring : TTFB à 14 secondes sur toutes les pages, 503 à la chaîne, mémoire PHP saturée. La veille, le trafic organique était normal. Ce soir-là, un administrateur du back-office avait lancé un export CSV en une seule requête, sans pagination ni streaming. Le script chargeait toute la table en mémoire, construisait une chaîne géante et la balançait d’un coup. Résultat : un worker PHP-FPM bloqué plusieurs secondes, tous les autres visiteurs en file d’attente, et Googlebot qui repartait avec un timeout.

Sur les audits de performance côté serveur, on retrouve presque toujours au moins un export CSV non maîtrisé capable de couler un site sous une charge modeste. Et comme le TTFB fait partie des signaux des Core Web Vitals, l’impact dépasse l’expérience utilisateur : il touche la capacité de Google à crawler le site sans erreurs.

Le piège du fichier CSV qui te coule un vendredi soir

Tu développes un back-office, tu ajoutes un bouton « Exporter en CSV », tu le testes avec 50 lignes sur ton poste, ça fonctionne. Six mois plus tard, le volume de données a été multiplié par mille et personne n’a revu la fonction. Le jour où un collègue lance l’export complet, le serveur s’effondre.

En PHP, la tentation est grande d’accumuler les lignes dans un tableau, d’utiliser implode ou des concaténations successives, puis de tout envoyer d’un bloc avec echo ou file_put_contents. Ça passe sur des exports trivials. Au-delà de quelques milliers de lignes, la mémoire atteint la memory_limit, le temps d’exécution dépasse la max_execution_time, et le processus reste verrouillé sans rien restituer au client pendant des secondes entières.

⚠️ Attention : un seul worker PHP-FPM bloqué sur un export long peut saturer le pool si le site reçoit du trafic en parallèle. Des erreurs 503 apparaissent alors même que le CPU n’est pas à 100 %. C’est le symptôme typique d’un goulot d’étranglement applicatif, pas d’une pénurie de ressources matérielles.

Une fuite de TTFB qui touche aussi Googlebot

Googlebot mesure un TTFB comme n’importe quel navigateur. Si un export CSV monopolise un worker PHP au moment où le bot arrive, la requête patiente. Et si le bot tombe systématiquement sur des timeouts, il réduit son rythme de crawl. On a vu un site perdre 40 % de son crawl budget après une migration où une poignée d’exports mal conçus faisaient planter le serveur. La Search Console ne mentionne jamais le CSV, elle affiche juste une explosion des erreurs « indisponible » et une baisse du nombre d’URL crawlées par jour.

Dans notre dossier sur l’optimisation des Core Web Vitals, on rappelait déjà que le TTFB est le premier maillon du LCP. Un serveur qui répond en 3 secondes au lieu de 300 ms repousse le Largest Contentful Paint d’autant, même si le front-end est parfait.

Lire et écrire ligne par ligne sans tout stocker en mémoire

La solution commence par un changement de posture : traiter le CSV comme un flux, pas comme une structure de données qu’on assemble en mémoire. PHP fournit les outils pour ça depuis longtemps. Tu ouvres un pointeur de sortie avec fopen('php://output', 'wb'), tu relies ta requête SQL à un curseur non bufferisé (par exemple en PDO avec PDO::MYSQL_ATTR_USE_BUFFERED_QUERY à false), et tu écris chaque ligne avec fputcsv dès qu’elle est lue.

header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="export.csv"');
$output = fopen('php://output', 'wb');

$stmt = $pdo->prepare('SELECT * FROM orders WHERE created_at >= ?');
$stmt->setFetchMode(PDO::FETCH_ASSOC);
$stmt->execute(['2026-01-01']);

while ($row = $stmt->fetch()) {
    fputcsv($output, $row);
}
fclose($output);

Ce code ne consomme pas plus de mémoire qu’une ligne. La durée d’exécution dépend du volume, mais aucune donnée n’est accumulée dans une variable géante. Le navigateur commence à recevoir les premiers octets quasiment tout de suite, ce qui fait passer le TTFB sous la seconde même pour des exports volumineux.

Le piège classique sur ce snippet : oublier PDO::MYSQL_ATTR_USE_BUFFERED_QUERY à false. Par défaut, PDO charge l’intégralité du jeu de résultats MySQL côté PHP avant la première itération du while, et tu retrouves exactement le problème de mémoire que tu voulais éviter. C’est silencieux : aucun warning, aucune erreur, juste un script qui consomme toute la RAM avant d’écrire la première ligne. Pour les drivers Postgres, le réglage équivalent passe par pg_query plutôt que pg_fetch_all, et par setFetchMode sans hydrater de tableau intermédiaire.

Les développeurs front passent des heures à optimiser le state management avec Zustand pour gratter 50 ms de rendu sur une interface React, comme on le détaillait dans notre article sur le state management React. Pendant ce temps, un script PHP qui ignore le streaming peut faire perdre 3 secondes de TTFB.

Compression gzip et flush : le duo qui sauve les apparences

Tu peux encore réduire le temps perçu et la bande passante en activant la compression à la volée. Plutôt que d’attendre la fin du fichier pour compresser, tu envoies des blocs compressés régulièrement. Ça s’obtient en combinant ob_start('ob_gzhandler') et des appels à ob_flush() et flush() toutes les N lignes. Attention : selon la configuration de ton serveur, les buffers intermédiaires (proxy, FastCGI) peuvent annuler l’effet, donc teste en condition réelle.

ob_start('ob_gzhandler');
$output = fopen('php://output', 'wb');
$batchSize = 500;
$i = 0;
while ($row = $stmt->fetch()) {
    fputcsv($output, $row);
    $i++;
    if ($i % $batchSize === 0) {
        ob_flush();
        flush();
    }
}
ob_end_flush();
fclose($output);

Sur un export de 80 Mo, la compression gzip ramène le volume transféré autour de 8 Mo selon la redondance des données. Le TTFB ne bouge pas mécaniquement, mais le temps de téléchargement total est divisé par dix, et le navigateur comme Googlebot décompressent le flux sans attendre la fin. Le gain est réel, surtout si le serveur est hébergé sur une liaison limitée ou si l’utilisateur est en mobilité.

Au-delà du streaming, le passage en asynchrone

Il y a un seuil où le streaming ne suffit plus. Si la requête SQL met 15 secondes à s’exécuter ou si le fichier doit subir des transformations complexes, bloquer une requête HTTP le temps de tout produire reste un pari risqué. La bascule à faire, c’est une file d’attente.

Le principe : l’utilisateur déclenche l’export, un job est poussé dans Redis ou dans une table dédiée, et un worker en arrière-plan génère le CSV. Une fois le fichier prêt, il le dépose sur un stockage accessible (S3, disque local) et notifie l’utilisateur par email ou via une notification dans l’interface. Le temps d’exécution disparaît du parcours utilisateur, et il ne monopolise plus aucun worker PHP-FPM dédié au front.

On a déjà diagnostiqué ce type de bascule en épluchant les logs PHP-FPM avec Claude Code, un outil qu’on a comparé en détail avec Cursor. En quelques minutes, on identifie la trace de la requête bloquante et on isole l’endpoint à migrer en asynchrone. Sans ce genre de visibilité, un export lent peut rester sous les radars pendant des mois.

Les images, l’angle mort des exports catalogue

Une colonne d’URLs d’images absolues dans le CSV, et chaque réimport ou script de moulinette déclenche des centaines d’allers-retours vers ton CDN. Exporte les identifiants, pas les liens. Si le destinataire a besoin des visuels, fournis un endpoint séparé qui sert des miniatures.

Questions fréquentes

Est-ce qu’un CDN peut mettre en cache un export CSV dynamique ? Non, sauf si tu déposes le fichier final sur un bucket accessible derrière le CDN. Un export généré à la demande ne peut pas être caché par un CDN sans stratégie explicite de purge et d’invalidation. La bonne approche reste de produire le fichier en tâche asynchrone, de le stocker avec une durée de vie définie, puis de le servir via une URL signée si besoin.

Faut-il privilégier le format Parquet ou JSON Lines pour les gros volumes ? Si tu exportes pour de la data science, Parquet est plus compact et plus rapide à lire dans des notebooks. Pour un usage bureautique, le CSV reste universel. JSON Lines est un bon compromis pour des exports structurés avec des objets imbriqués, mais tu perds l’ouverture immédiate dans Excel. La question n’est pas le format absolu, mais de savoir qui va consommer le fichier et avec quels outils.

Comment éviter qu’un export CSV expose des données sensibles sans le vouloir ? Ne sélectionne que les colonnes strictement nécessaires dans ta requête SQL. Applique une transformation de masquage sur les champs comme l’email ou le téléphone si l’export part en dehors de l’organisation. Et surtout, stocke le fichier généré dans un emplacement non indexable, avec une politique de suppression automatique.

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.