Your PDF has the text but blank gaps (or broken-image icons) where the photos should be. The page lazy-loads its images with JavaScript: they only get a src once they scroll into view, and page.pdf() never scrolls. We measured it with Puppeteer 25.15 and Chrome 155 (Playwright's Chromium does the same) on a test page with 24 images, counting the images in each PDF with pdfimages -list.
| How the image is lazy-loaded | In the PDF |
|---|---|
Native <img loading="lazy">Chrome loads these before printing | 12 of 12 |
JavaScript: data-src swapped in by an IntersectionObserver (lazysizes, many WordPress themes and plugins)same with waitUntil: 'load' or 'networkidle0' | 0 of 12 |
| Same page, scrolled to the bottom and waited for images first (the fix below) | 12 of 12 |
No error is raised: the PDF renders, just with empty boxes. If the images have an alt attribute, Chrome prints a small broken-image icon instead, which pdfimages lists as a 14×16 image, so check image sizes, not just the count. Waiting longer doesn't help, because nothing ever scrolls the page; networkidle0 just waits for a network that is already idle.
Scroll down one viewport at a time so every IntersectionObserver fires, scroll back to the top, wait until every <img> has loaded or failed, then call page.pdf(). Save as lazy-pdf.mjs and run node lazy-pdf.mjs https://your-page:
import puppeteer from 'puppeteer';
const url = process.argv[2] ?? 'https://example.com';
const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); // --no-sandbox: Ubuntu 23.10+ / Docker
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'networkidle2' });
// 1. Scroll down one viewport at a time so IntersectionObserver-based loaders fire.
await page.evaluate(async () => {
for (let y = 0; y < document.documentElement.scrollHeight; y += innerHeight) {
window.scrollTo(0, y);
await new Promise(r => setTimeout(r, 100));
}
window.scrollTo(0, 0);
});
// 2. Wait until every <img> has loaded or failed (max 10s), then print.
await page.evaluate(() => Promise.race([
Promise.all([...document.images].map(img => img.complete ? null :
new Promise(r => { img.addEventListener('load', r); img.addEventListener('error', r); }))),
new Promise(r => setTimeout(r, 10000)),
]));
await page.pdf({ path: 'out.pdf', format: 'A4', printBackground: true });
await browser.close();
On our 24-image test page this produced a 21-page PDF with all 24 images and no broken icons, in about 4.5 seconds. Notes:
page.evaluate blocks work unchanged; only the launch differs (chromium.launch(), page.setViewportSize(), waitUntil: 'networkidle').y < Math.min(document.documentElement.scrollHeight, 50000).data-bg by the same kind of script are fixed by the same scroll, but the document.images wait doesn't see them; add a short fixed delay after scrolling if you rely on them.<img src> or loading="lazy", which print fine.fullPage: true; the Puppeteer screenshot guide uses the same scroll.To check your own setup, print our lazy image test page (12 native and 12 JavaScript lazy images) and count: a complete PDF has all 24. (Our API can't fetch pages from its own domain, so use it with your local Puppeteer or Playwright, not with the API.)
Since 20 October 2026, /v1/pdf scrolls the page before printing (up to 60,000 px or 5 seconds) and waits for the images that scrolling started, so JavaScript lazy images are in the PDF with no extra code. Pages with nothing to load cost about 0.1 seconds more; before this fix, our API missed these images too. Node 18+ (save as page-pdf.mjs, set SHOTMINT_KEY):
import { writeFileSync } from 'node:fs';
const res = await fetch('https://shotmint.creasework.com/v1/pdf', {
method: 'POST',
headers: { 'X-API-Key': process.env.SHOTMINT_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: process.argv[2] ?? 'https://example.com', format: 'A4' }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
writeFileSync('page.pdf', Buffer.from(await res.arrayBuffer()));
console.log('page.pdf written, credits left:', res.headers.get('x-credits-remaining'));
Python with requests:
import os, sys, requests
url = sys.argv[1] if len(sys.argv) > 1 else "https://example.com"
res = requests.post(
"https://shotmint.creasework.com/v1/pdf",
headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
json={"url": url, "format": "A4"},
)
res.raise_for_status()
open("page.pdf", "wb").write(res.content)
print("page.pdf written, credits left:", res.headers.get("x-credits-remaining"))
Both scripts were run against the live API (1 credit each). The fix itself was checked by sending the test page's HTML, with its images on a third-party host, to /v1/pdf: all 24 images were in the PDF.
pdfimages -list out.pdf (poppler-utils) and check their sizes, not just that the PDF exists.img.complete on every image.Related: custom fonts in HTML to PDF, HTML to PDF with charts, HTML to PDF in Node.js, Puppeteer alternative, free HTML to PDF converter.
One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).
All guides: guides · integrations · comparisons