optimisation core web vitals 7 min

Tutoriel Symfony & DomPDF : générer des PDF sans plomber le TTFB

Générez des PDF avec Symfony et DomPDF sans tuer le TTFB. Cache, file d’attente, génération asynchrone : trois leviers concrets pour garder un temps de réponse serveur sous contrôle.

Par Julien Morel
Partager

Un client nous contacte, paniqué : son site e-commerce voit le TTFB de ses fiches produit bondir dès qu’un utilisateur clique sur « Télécharger la fiche en PDF ». On ouvre le code, et on tombe sur un contrôleur Symfony qui instancie DomPDF au beau milieu de la requête, génère un PDF de douze pages et le renvoie dans la réponse. Le temps de réponse serveur passe de 180 ms à plus de 3 secondes. Le Core Web Vitals de la page produit ne souffre pas directement — la navigation reste en HTML — mais la lenteur de cette route PDF contamine l’expérience et, surtout, bloque un processus PHP pendant que d’autres visiteurs attendent.

Le pire, c’est que le fonctionnement semble « normal » pour l’équipe : une librairie PHP, un fichier PDF, un return new BinaryFileResponse(). Personne n’a mesuré précisément ce que cette ligne coûtait au serveur, ni remis en cause l’architecture. C’est exactement ce qu’on va démonter ici, en gardant DomPDF et en le faisant bosser sans flinguer le TTFB.

Ce qui plombe vraiment le temps de réponse, ce n’est pas le framework, c’est la librairie

Symfony n’est pas en cause. Le framework répond en quelques millisecondes pour une route classique. Le problème vient du boulot que DomPDF exécute à chaque appel : parsing du DOM, résolution du CSS, calcul de la mise en page, rendu des polices et écriture du flux binaire. Sur un document un peu riche, la consommation mémoire flirte avec les 128 Mo et le traitement prend plus de 800 ms, voire bien au-dessus sur un serveur partagé.

On a isolé la mesure. Un template Twig de fiche produit avec un tableau de caractéristiques, trois images et une police Google Fonts appelée via @import. Sans cache, la génération avoisine 1,8 seconde sur une machine de développement standard. C’est long. C’est surtout du temps CPU pendant lequel le processus PHP ne répond plus à rien d’autre. Multipliez par quelques utilisateurs simultanés, et l’application entière ralentit.

La racine du mal, c’est que la génération se fait dans le chemin critique de la requête. Tant que le PDF n’est pas prêt, le client HTTP attend. Et comme le PHP exécute tout en synchrone, la librairie n’a aucune échappatoire.

Trois architectures pour sortir la génération PDF du chemin critique

Sortir DomPDF du cycle requête/réponse, c’est l’unique moyen de préserver un TTFB décent sur la route de téléchargement et de ne pas saturer les workers PHP. Trois montages y parviennent, avec des compromis différents.

ApprocheImpact sur le TTFBFraîcheur du PDFComplexité
Cache statique (disque ou CDN)Immédiat (fichier servi directement)Différée (régénération manuelle ou cron)Faible
Job asynchrone (Messenger) + pollingLa requête initiale retourne un statut, le TTFB est minimeQuelques secondesMoyenne
Pré-génération au moment de l’édition du contenuAucun (PDF déjà créé)ImmédiateFaible si l’admin déclenche le job

Le cache statique est le plus simple : on génère le PDF une fois, on le stocke, et on le sert via le serveur web ou un CDN. Le TTFB devient celui d’un fichier statique. L’inconvénient, c’est que si le contenu change souvent, il faut un mécanisme d’invalidation : un écouteur d’événement Doctrine qui supprime le fichier périmé et un événement TerminateEvent qui relance la génération en arrière-plan.

L’approche asynchrone avec Messenger consiste à recevoir la demande, créer un job GeneratePdf, et répondre immédiatement au client avec un statut 202 Accepted ou une redirection vers une URL qui sera pollée. Le job, dépouillé du contexte HTTP, est exécuté par un worker séparé. On y gagne non seulement en TTFB, mais aussi en résilience : le PDF peut mettre cinq secondes à générer, personne n’attend.

La pré-génération est la plus propre quand le contenu est administré : au moment de la sauvegarde d’un article ou d’une fiche produit dans le back-office, on pousse un message dans la file. Le PDF est déjà prêt quand l’internaute clique. L’effet sur le TTFB est éliminé par la racine.

Optimiser le HTML en amont pour ne pas faire souffrir DomPDF

On peut alléger la charge de DomPDF sans toucher à l’architecture. Le rendu d’un PDF en tâche de fond bénéficie autant d’un HTML léger que s’il était synchrone : ça réduit le temps de job, la mémoire utilisée et les risques d’erreur fatale sur les gros volumes.

D’abord, virer tout ce qui n’est pas destiné au print. Créer une feuille de style @media print spécifique et cacher les menus, les boutons, les carrousels avec display: none. DomPDF consomme du temps CPU à parser et positionner des éléments que vous ne verrez jamais sur le PDF. Ensuite, remplacer les images lourdes par des versions WebP légères ou, mieux, les servir en local plutôt que depuis un CDN ? Non, le CDN est bien, mais éviter les appels réseau externes pendant le rendu : DomPDF va chercher les images via file_get_contents, ce qui ajoute de la latence réseau. Préférez des URLs locales ou injectez l’image en base64 après l’avoir téléchargée côté serveur (en limitant le poids).

Côté polices, le pire ennemi ce sont les @import qui pointent sur Google Fonts. DomPDF va télécharger chaque police à la volée, parfois en double pour les variantes. On y vient juste après.

Le piège des polices web chargées à chaque génération

À chaque $dompdf->render(), DomPDF résout toutes les ressources externes. Une simple ligne @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700') déclenche une requête HTTP et injecte une fonte de plusieurs centaines de kilo-octets. Sur un volume de 100 PDF quotidiens, c’est une pollution réseau et un ralentissement assuré.

La parade tient en deux actions : télécharger les polices, les stocker en local, et utiliser @font-face avec des chemins absolus sur le système de fichiers. Ensuite, passer un coup d’outil pyftsubset pour ne garder que les glyphes utilisés. Un PDF de 20 pages, ça ne contient jamais la totalité des 900 caractères d’une Inter complète.

Que faire des PDF pour l’indexation : canonical et headers HTTP

Les PDF générés à la volée ne sont pas anodins pour le crawl. Si vous laissez Googlebot les découvrir parce qu’un lien pointe vers /produit/123.pdf, vous mangez du budget de crawl sur des fichiers binaires qui n’apportent rien à l’indexation de votre contenu. Pire, si le PDF est indexé, il peut concurrencer la page HTML, parfois sur les mêmes mots-clés, sans la même capacité de conversion.

Ici, on va reprendre le contrôle avec deux directives simples : l’en-tête X-Robots-Tag: noindex, follow sur la réponse du PDF (ajouté dans le contrôleur ou au niveau du serveur), et un élément <link rel="canonical" href="https://exemple.com/produit/123" /> pointant vers la page HTML, accessible même pour un fichier binaire. Pas de noindex dans un meta tag (inutile en PDF), mais l’en-tête HTTP fait le job. Résultat : le crawler sait que l’autorité se reporte sur la page HTML, et il ne gaspille pas votre quota quotidien à digérer un fichier de 2 Mo qui ne changera pas assez vite. C’est un détail qui protège votre crawl budget sans ajouter une ligne de code métier, et qui s’inscrit parfaitement dans une optimisation globale des Core Web Vitals parce qu’un crawl maîtrisé préserve vos ressources serveur.

DomPDF dans une stack découplée : quand le front envoie les données

De plus en plus de projets Symfony servent d’API, avec un front React qui consomme les endpoints. Dans ce cas, la génération PDF peut être déclenchée par une requête AJAX. Le contrôleur API reçoit les données (ou un identifiant), et les achemine au job asynchrone. Le même principe s’applique : aucune attente synchrone.

Si le front React a besoin d’agréger des informations issues de plusieurs appels avant de demander le PDF, un state management léger avec Zustand simplifie la collecte des champs sans multiplier les requêtes. Le store centralise l’état du formulaire ou de la fiche, et le composant envoie un payload JSON unique à l’API Symfony une fois toutes les données prêtes. Ce n’est pas DomPDF qui est concerné directement, mais le workflow global qui gagne en fiabilité. Lorsqu’on compare les outils de l’environnement de développement, on pourrait opposer Claude Code et Cursor IDE pour évaluer où le diagnostic de perf sera le plus rapide, mais dans tous les cas, le code final doit rester simple et sans blocage.

Questions fréquentes

Peut-on générer un PDF avec DomPDF en moins de 500 ms ?

Oui, sur un document très court et avec une version récente de DomPDF couplée à un serveur bien dimensionné. Dès que vous ajoutez des images, des polices et des mises en page complexes, les 500 ms sont rarement tenues. Pour rester sous ce seuil, la solution la plus fiable reste de pré-générer le PDF et de le servir en cache.

Faut-il abandonner DomPDF pour Puppeteer ou Wkhtmltopdf ?

Pas systématiquement. DomPDF reste pertinent en pur PHP, sans dépendance système lourde. Puppeteer apporte une fidélité de rendu quasi native mais exige un navigateur headless et plus de mémoire. Le critère décisif n’est pas l’outil, c’est le fait de déporter la génération hors du cycle requête/réponse. Remplacez DomPDF si le rendu CSS est trop limité, pas à cause du TTFB.

Comment éviter qu’un PDF régénéré en arrière-plan ne soit servi partiellement ?

Utilisez un fichier temporaire, renommez-le atomiquement une fois la génération terminée. Symfony Filesystem et la méthode rename() garantissent que le fichier final n’est visible qu’une fois complet. Les risques de servir un PDF corrompu deviennent nuls, même pendant un redéploiement.

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.