The journal · 19 July 2026 · 8 min read
How to wait for JavaScript and charts to render before HTML to PDF
An empty box where the chart should be is the classic HTML to PDF bug: the PDF was captured before the JavaScript finished. Here is why it happens, the wait signals that actually work (and the one that does not), and how to make dashboards and font-heavy pages render every time.
You wire up HTML to PDF, it works on a plain page, and then you point it at your real dashboard and the chart is a blank box. Or the numbers are there but the branded font fell back to Times. Or half the report is missing because it loads as you scroll. Every one of these is the same bug wearing a different hat: the PDF was captured before the page finished rendering. The page looks complete to a human who waited a second, but the converter did not wait, it grabbed the DOM the instant the HTML arrived and printed a half-built page.
The short version
Blank charts and missing content in an HTML to PDF are a timing problem, not a rendering one. JavaScript that draws charts, fetches data or loads images runs after the initial HTML, so the fix is to make the converter wait for a reliable completion signal before it captures the page. The signals that work, in rough order of reliability, are: wait for a specific element or selector that only exists once rendering is done, wait for network activity to go idle, or as a last resort wait a fixed number of milliseconds. Waiting on a real selector is by far the most dependable; a fixed delay is a guess that is either too short and flaky or too long and slow.
Why the content is missing in the first place
When a browser loads a page, the HTML arrives first and the DOM exists almost immediately, but that is not the finished page. Then the JavaScript runs: a charting library like Chart.js or Highcharts draws its canvas or SVG, a data-fetching component calls your API and swaps in the results, images and web fonts download over the network, and lazy sections mount as they enter the viewport. All of that happens in the moments after the initial load. A naive converter that renders on the load event, or worse on nothing at all, snapshots the page during that window and captures the skeleton, not the finished document.
This is why the same converter can look perfect on a static marketing page and fail on an app dashboard. The static page is done the moment its HTML lands. The dashboard is not done until several seconds of JavaScript have run, and nothing about the raw HTML tells the converter that.
Wait for a selector: the reliable signal
The most dependable approach is to tell the converter exactly what "finished" looks like by naming an element that only appears once rendering is complete. This ties the wait to your actual content rather than to a clock. If your chart component sets an attribute or renders a specific node when it has drawn, wait for that.
With Puppeteer or Playwright directly, that is waitForSelector:
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForSelector("#revenue-chart canvas", { timeout: 15000 });
const pdf = await page.pdf({ format: "Letter", printBackground: true });
The trick that makes this robust is adding a tiny, explicit "I am done" marker in your own rendering code. When your dashboard has finished drawing every chart and filling every value, set a flag, for example document.body.dataset.ready = "true", and have the converter wait for body[data-ready="true"]. Now the wait is tied to your definition of complete, not to a fragile guess about which internal element appears last. This one habit eliminates the large majority of blank-chart reports.
Wait for network idle: good for data and images
When the page is not done until some API calls return and images load, waiting for the network to go quiet is a strong second option. Puppeteer and Playwright expose this as a wait condition (networkidle0 means no network connections for 500ms). It handles the common case of a component that fetches data on mount and then paints, because the paint follows the fetch and the fetch is the last network activity.
Network idle has two blind spots worth knowing. A page that polls or holds an open connection, a live-updating dashboard, a websocket, may never reach idle, so you pair it with a selector wait or a timeout. And network idle tells you the data arrived, not that the chart finished animating, so for canvas charts with entrance animations you often still want a short settle or a selector on the finished state.
Do not forget the fonts
A subtler version of the same bug is web fonts. If the converter captures before your custom font has downloaded, the browser renders with a fallback and your PDF looks off-brand, with different metrics that can even shift your layout. Browsers expose document.fonts.ready, a promise that resolves when all fonts in use have loaded, and waiting on it before capture removes the fallback-font surprise.
await page.evaluateHandle("document.fonts.ready");
The fixed delay: last resort, not first reach
The tempting fix is a flat "wait three seconds" before capturing. Avoid making it your primary strategy. A fixed delay is a bet against a clock you do not control: set it short and it is flaky, failing whenever the API is slow or the user's chart has more data than usual, set it long and every single PDF is needlessly slow, which hurts badly at volume. A fixed delay is reasonable only as a small final settle on top of a real signal, for example wait for the selector, then pause 250ms for an animation to finish. It should never be the only thing standing between you and a correct render.
How this works through an API
If you have offloaded rendering to a hosted API rather than driving Puppeteer yourself, the same signals are exposed as parameters, because the API is running the same browser under the hood. You pass the selector to wait for, and the render blocks until that element exists before it captures.
curl https://api.sitepdf.com/v1/render \
-H "Authorization: Bearer $SITEPDF_KEY" \
-F url=https://app.example.com/reports/q3 \
-F wait_for="body[data-ready='true']" \
-F format=Letter \
-F archive=true
The pattern is identical to doing it by hand: name a real signal, prefer a selector on your own completion marker, and let the render wait on it. Because a managed browser does the work, your charts, fonts and fetched data all render exactly as they do on screen once the wait resolves. The full parameter list is on the PDF generator API page, and if you are weighing running the browser yourself instead, the Puppeteer alternative page covers that trade honestly. When these reports are financial dashboards, it also helps to be able to trace where the numbers on them come from, so a wrong figure in a PDF can be tracked back to its source rather than argued about.
A quick decision guide
| What is missing | What to wait for |
|---|---|
| Chart is a blank box | A selector on the drawn chart, ideally your own data-ready marker |
| Data from an API is absent | Network idle, plus a selector on the populated content |
| Wrong or fallback font | document.fonts.ready before capture |
| Lazy-loaded sections missing | Scroll the page to trigger loading, then wait for the last section |
| Live or polling page never settles | A selector wait with a timeout, not network idle alone |
The mental model that fixes this for good: your converter needs a definition of "done," and the raw HTML does not provide one. Give it a real signal to wait on, make that signal a completion marker you set in your own code, and the blank-chart bug stops happening. A fixed delay only ever hides the problem, and it charges you in either reliability or speed for the privilege.
Written by the team building Sitepdf, an HTML to PDF API that archives every page it renders. The in-browser converter is free to try; early access locks the launch pricing.