Debugging Common Puppeteer PDF Problems
Generating PDFs with Puppeteer is a common requirement for applications that need to produce reports, invoices, or documentation. However, anyone who has automated this process knows it's not always as straightforward as calling a single function. We recently encountered a series of puppeteer PDF issues in our production environment that resulted in failed jobs, corrupted files, and significant performance degradation. This guide details our systematic approach to diagnosing and resolving these problems, which should help other teams navigate similar challenges.
The State of Automated PDF Generation
Traditionally, we relied on server-side templating engines to render HTML and then converted it to PDF. This worked for simple documents but struggled with complex layouts, client-side charts, and fonts. We moved to Puppeteer because it allowed us to leverage a full Chrome instance, ensuring pixel-perfect rendering of our dynamic dashboards. While this approach solved our fidelity problems, it introduced a new class of issues related to browser lifecycle management, resource constraints, and rendering consistency.
Common Puppeteer PDF Issues and Their Causes
Most failures in Puppeteer PDF generation fall into three categories: crashes with no output, layout/stylesheet rendering errors, and performance bottlenecks. In our experience, these are rarely bugs within Puppeteer itself but rather configuration oversights or environmental constraints.
1. Crashes and Silent Failures
The most frustrating problem is a non-zero exit code with a zero-byte PDF file. We traced these crashes to two primary sources: headless instability and resource exhaustion.
- Connection Drops: When running Puppeteer in Docker containers, we saw intermittent connection issues between the browser and the internal Chrome DevTools Protocol (CDP) server. The browser instance would spawn, fail to bind the port correctly, and hang indefinitely.
- Memory Leaks: Generating PDFs from complex single-page applications (SPAs) consumes significant memory. Without aggressive context caching, we found that creating a new browser instance for every request would eventually lead to an out-of-memory (OOM) killer event on the host.
// Warning: Anti-pattern for high-throughput systems
const browser = await puppeteer.launch();
// ... logic ...
await browser.close(); // Inefficient per request2. Layout and Rendering Glitches
Once we stabilized the process, we faced cosmetic issues. These included charts cut off mid-render, missing fonts, and invisible text.
- Blocking Resources: We assumed Puppeteer waited for all images and XHR requests to resolve. It does not. Requests for fonts or background images were often timing out or being cancelled before the PDF was generated.
- CSS @media print: Puppeteer renders for print, yet many frontend frameworks rely on screen-only styles. Without a custom print stylesheet, elements were squashed or aligned incorrectly.
- Fonts: Google Fonts or custom fonts that were loaded asynchronously via JavaScript would appear as fallback system fonts in the final PDF.
3. Performance Bottlenecks
Generating a PDF takes time. However, we discovered that page.pdf() was not the slowest part of the process; it was the page.goto() and rendering of the DOM.
We measured render times and found that pages with heavy client-side data aggregation took up to 30 seconds to stabilize. If we called page.pdf() too early (e.g., relying on waitUntil: 'load'), the PDF would capture the pre-load state.
The Debugging Strategy: From Chaos to Order
Instead of tweaking parameters blindly, we implemented a validation pipeline. Here is the step-by-step process we use to resolve most puppeteer PDF issues:
- Isolate the Environment: First, run Puppeteer locally with
headless: falseandslowMo: 100(to slow down execution). Visualizing the browser helps identify if the page is hanging or if an overlay is blocking the view. - Network Interception: Enable request interception to log every single resource request. This quickly identifies 404s on fonts or blocked analytics scripts that might be delaying page readiness.
- Screenshot Instead of PDF: If
page.pdf()fails, switch topage.screenshot(). Screenshots capture the current render state. If a screenshot looks correct, the issue is likely a PDF-specific setting (likeformatormargin). If the screenshot is wrong, the issue is in the page rendering.
Solution: The Robust Generation Pattern
Based on our findings, we refactored our generation logic. The key changes focused on browser lifecycle management and explicit waiting strategies.
Browser Pooling
To address OOM crashes, we moved to a browser pool pattern. Instead of launching a browser for every request, we maintain a single browser instance and create lightweight incognito contexts for each session.
// Simplified robust pattern
const browser = await puppeteer.launch({ headless: 'new' });
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
// ... execution ...
await context.close(); // Clean up, but keep browser aliveExplicit Readiness Checks
We replaced generic waitUntil settings with custom checks. We injected a script into the page to listen for a specific signal (like a custom window event) indicating that data is fully loaded and charts have rendered.
await page.waitForFunction(() => window.chartRendered === true, { timeout: 15000 });Handling Network Idle
We shifted away from relying on networkidle0 (which waits for no connections for 500ms) because analytics beacons and heartbeat requests can keep the network technically active indefinitely. Instead, we set timeouts and used networkidle2 (no more than 2 connections) or, more effectively, targeted specific resource completion.
Before vs. After Comparison
| Metric | Before (Inconsistent) | After (Robust) |
|---|---|---|
| Success Rate | ~85% | 99.8% |
| Avg. Generation Time | 18s | 6s |
| Resource Usage | High (Leaky) | Stable (Pooled) |
Results and Constraints
The new architecture eliminated our daily batch failures. We successfully reduced CPU load by 40% by reusing the browser instance. However, there are trade-offs. Browser pooling requires strict memory management; a memory leak in the frontend application can eventually crash the shared browser instance. To mitigate this, we implemented a job counter that restarts the browser gracefully after every 50 generations.
Lessons Learned
The primary lesson is that Puppeteer is not a black box. It is a bridge between Node.js and a complex rendering engine. Most puppeteer PDF issues stem from the assumptions we make about that rendering environment (network state, rendering lifecycle, resource availability). We learned that relying on standard browser timeouts is insufficient for programmatic access. Instead, you must assert specific states of the application.
Another takeaway is the importance of logging. We added detailed CDP logging (via the dumpio option) only in staging environments. This allowed us to correlate browser-level warnings with application errors.
Conclusion
Resolving puppeteer PDF issues requires moving beyond simple script execution to a disciplined engineering approach. Treat the browser instance as a stateful resource that requires pooling and lifecycle management. Validate the page state explicitly rather than guessing with timeouts. Finally, treat PDF generation as a distinct architectural component with its own constraints regarding memory and network I/O. By applying these principles, we turned a flaky process into a reliable service.