HTML to PDF with lazy-loaded images

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.

What actually breaks

How the image is lazy-loadedIn 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.

The fix: scroll, wait for images, then print

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:

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.)

With the Shotmint 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.

Lazy image checklist

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.

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription.

Try it with 25 free renders a month

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).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons