You render an HTML report with a headless browser and the PDF comes back with empty chart areas, half-drawn bars, or blank gaps where images should be. Open the same page in Chrome and everything is there.
Why it happens
A PDF render is a snapshot of whatever is painted when the print step runs. Most pipelines treat the page's load event as "ready," and that event does not cover three common kinds of content:
- Data fetched after load. If a chart script calls fetch() and draws when the response arrives, the load event can fire before any of that happens.
- Chart animations. Chart.js animates by default, with a default duration of 1000 ms, so an early snapshot catches the chart mid-draw.
- Lazy images. loading="lazy" defers images that sit outside the viewport, and the load event does not wait for them. A render that never scrolls may snapshot before they are requested.
One Puppeteer gotcha makes this worse: page.setContent() does not accept waitUntil: 'networkidle0' or 'networkidle2'. Its options type excludes both values, so a pattern copied from page.goto() examples does not carry over. Wait for the network in a separate call instead.
The fix
Make the page tell you when it is done, and wait for that signal explicitly. In the template, turn off animation, set a flag after the last chart draws, and drop loading="lazy" from images:
<!-- In the template: draw charts without animation, then signal completion -->
<script type="module">
// Chart.js is loaded by an earlier <script> tag. Use absolute URLs:
// a new page filled with setContent() is still at about:blank.
const res = await fetch('https://example.com/api/report-data');
const data = await res.json();
new Chart(document.getElementById('revenue'), {
type: 'bar',
data,
options: { animation: false }
});
window.__renderDone = true;
</script>Then wait for the network and the flag before printing:
// html: your rendered template string
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch();
const page = await browser.newPage();
// setContent waits for 'load' by default; networkidle options are not accepted here
await page.setContent(html, { waitUntil: 'load' });
// Wait for requests your scripts make after load (chart data, images)
await page.waitForNetworkIdle({ idleTime: 500 });
// Wait for the page to say it finished drawing
await page.waitForFunction(() => window.__renderDone === true, { timeout: 15000 });
const pdf = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();Caveats
- Keep the timeout. If a chart script throws, the flag never gets set and waitForFunction rejects with a timeout error. That is what you want: a failed job you can retry beats a blank PDF sent to a customer.
- Fonts are usually not the timing problem. In current Puppeteer, page.pdf() waits for document.fonts.ready by default (the waitForFonts option). If text renders in the wrong font, look at font loading and embedding rather than adding more waits.
