Générer des PDF avec Puppeteer dans NestJS : ce qu'on a appris en production

On a utilisé Puppeteer pour générer des factures et des formulaires administratifs en PDF dans deux projets NestJS. C'est puissant, mais ça vient avec des pièges qu'on n'a trouvés dans aucun tutoriel.

Pourquoi Puppeteer

Le besoin : générer des PDF à partir de données dynamiques : factures avec détails de paiement, formulaires pré-remplis avec 60+ champs, rapports personnalisés. Les alternatives (jsPDF, PDFKit) construisent le PDF pixel par pixel. Puppeteer prend du HTML et le rend en PDF, comme un navigateur qui "imprime" la page. Hyper pratique.

Pour nous, c'était le choix évident : on sait écrire du HTML comme des pros.

Le service de base

@Injectable()
export class PdfService {
  async generatePdf(htmlContent: string, outputPath: string) {
    const browser = await Puppeteer.launch({
      headless: true,
      pipe: true,
      executablePath: process.env.CHROMIUM_PATH,
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });

    const page = await browser.newPage();
    await page.setContent(htmlContent, { waitUntil: 'networkidle0' });
    await page.pdf({
      path: outputPath,
      width: 595,  // A4
      height: 842,
      printBackground: true,
    });

    await browser.close();
    return outputPath;
  }
}

Simple en apparence. Les problèmes arrivent après.

Piège #1 : Docker et Chromium

Puppeteer télécharge Chromium automatiquement en local. En Docker, c'est une autre histoire. Chromium a besoin de librairies système spécifiques. Notre Dockerfile inclut :

RUN apt-get update && apt-get install -y \
  chromium \
  fonts-liberation \
  --no-install-recommends

Et dans le service, on pointe vers le Chromium système :

executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium'

Tip : ne laissez pas Puppeteer télécharger son propre Chromium en Docker. Ça ajoute ~300 Mo à l'image et ça casse régulièrement entre les versions, c'est super chiant. Utilisez le Chromium du système. Vous nous remercierez.

Piège #2 : les fuites mémoire

Chaque Puppeteer.launch() crée un process Chromium. Si le browser.close() n'est pas appelé (exception, timeout), le process reste en mémoire. Après quelques dizaines de PDF, le serveur est à genoux. C'est vraiment pas joli à voir, croyez-nous.

Notre pattern : toujours fermer dans un finally.

let browser: Browser;
try {
  browser = await Puppeteer.launch(/* ... */);
  // ... générer le PDF
} finally {
  if (browser) await browser.close();
}

Tip avancé : pour du volume (plus de 10 PDF/minute), gardez une instance de browser ouverte et créez une nouvelle page à chaque PDF au lieu de relancer Chromium à chaque fois. On n'a pas eu besoin d'aller jusque-là, mais c'est le pattern recommandé pour la performance.

Piège #3 : waitUntil: 'networkidle0'

L'option networkidle0 attend que toutes les requêtes réseau soient terminées. Si votre HTML charge des images externes ou des fonts Google, Puppeteer attend qu'elles soient toutes chargées avant de générer le PDF.

En pratique, ça veut dire que si une image externe est lente ou en 404, votre génération de PDF est lente ou bloquée.

Tip : embarquez tout en inline : images en base64, CSS inline, pas de fonts externes. Le PDF se génère en millisecondes au lieu de secondes.

Piège #4 : les dimensions

width: 595 et height: 842 correspondent au format A4 en points (pas en pixels). Si vous mettez des pixels, vous aurez un PDF au format bizarre.

Pour les marges, on les gère directement en CSS dans le HTML plutôt qu'avec l'option margin de Puppeteer, plus de contrôle, et c'est plus facile à débugger en ouvrant le HTML dans un navigateur.

L'alternative : EJS pour le templating

Sur un autre projet, on utilise des templates EJS pour séparer la structure HTML des données :

template.ejs → inject données → HTML complet → Puppeteer → PDF

C'est plus maintenable que de construire le HTML dans le code TypeScript, surtout pour des documents complexes (factures avec lignes variables, en-têtes conditionnels).

Quand NE PAS utiliser Puppeteer

  • PDF simple (texte + tableau) : PDFKit ou jsPDF sont plus légers et ne nécessitent pas Chromium
  • Remplissage de formulaire PDF existant : pdf-lib manipule des PDF existants — parfait pour pré-remplir un formulaire officiel
  • Très haut volume : si vous générez des centaines de PDF par minute, un service dédié (ou un worker avec browser pool) vaut mieux qu'une instance Puppeteer dans votre API

On a d'ailleurs combiné les deux sur un projet : Puppeteer pour les factures (layout HTML complexe) et pdf-lib pour pré-remplir un formulaire ACRE officiel (60+ champs dans un PDF existant).

En contexte : on a utilisé Puppeteer pour les factures d'une marketplace de santé et pdf-lib pour les formulaires ACRE d'une plateforme de création d'entreprise.

Stack concernée : NestJS, Puppeteer, Docker, EJS, pdf-lib

Prêt à vous lancer ?

La newsletter qu'on n'ignore pas

Abonnez-vous à notre newsletter pour recevoir nos derniers articles, retours d'expérience et conseils tech directement dans votre boîte mail.

Désinscription en un clic. Vos données restent privées.