The journal · 19 July 2026 · 8 min read
HTML to PDF images not loading: why pictures come out blank and how to fix it
Your logo is missing, the product photos are empty boxes, and the chart image never appears. Images fail in HTML to PDF for four specific reasons, and lazy loading is the one almost nobody expects. Here is how to diagnose each and the fixes that make images render every time.
The invoice renders, the type is right, the layout holds, and where your logo should be there is a blank rectangle. Or the product photos halfway down the page are empty while the ones at the top came through fine. Missing images are a close second to CSS problems in HTML to PDF support threads, and like CSS problems, the randomness is an illusion. There are four causes, and the pattern of which images survive usually tells you which one you have.
The short version
Images fail in an HTML to PDF render for four reasons: the image URL is relative and the renderer cannot resolve it, the file sits behind auth or an expired signed URL so the request is refused, the image is lazy loaded and never enters the viewport during a headless render, or the PDF was captured before the download finished. Lazy loading is the one that produces the classic "first few images work, the rest are blank" pattern, and it is the one almost nobody suspects.
Reason one: relative image paths
If you POST an HTML string to a renderer rather than pointing it at a URL, relative paths have nothing to resolve against. <img src="/images/logo.png"> is meaningless without a base, so the request either never fires or goes somewhere unhelpful, and you get the broken-image placeholder or nothing at all.
The signature here is that every image fails at once, including the logo in your header. If nothing at all renders, start here before looking at anything more subtle.
Use absolute HTTPS URLs for every image in a document you intend to render. If your templates use a helper for asset paths, give it an absolute-URL mode for the PDF layout rather than patching individual tags.
<!-- unresolvable when the HTML is posted as a string -->
<img src="/images/logo.png" alt="Company logo">
<!-- resolvable anywhere -->
<img src="https://cdn.example.com/images/logo.png" alt="Company logo">
For small, stable images like a logo or a signature, there is a stronger option: embed the file as a base64 data URI. It makes the render fully self-contained, so there is no network request that can fail, no CDN that can be slow, and no dependency on the renderer's connectivity. The HTML gets larger, so keep it to assets measured in kilobytes, not full-page photography.
Reason two: the image is not publicly reachable
An absolute URL is not automatically a reachable one. The renderer is a different machine with no session, no cookies and no VPN. Anything gated will fail for it even though it loads perfectly in your browser, where you happen to be logged in.
Three variants come up repeatedly. Images behind application auth, where the URL returns your login page rather than a file. Images on internal or staging hosts that are not routable from the outside. And signed URLs, typically S3 or GCS presigned links, that expire between the moment your template was generated and the moment the render actually ran. That last one is nasty because it works in testing and fails in production, where a queued job might render twenty minutes after the HTML was built.
The test is quick: open the image URL in a private window with no session. If it downloads, the renderer can fetch it too. If you must use signed URLs, generate them at render time rather than template-build time, and give them a lifetime comfortably longer than your queue's worst-case delay.
Reason three: lazy loading, the one nobody expects
This is the cause behind the most confusing symptom in the whole category: the first screenful of images renders and everything below it is blank. If that is your pattern, you almost certainly have lazy loading in play.
Lazy loading is a performance optimization that defers image downloads until the image is about to scroll into view. It is excellent for real users and actively hostile to headless rendering, because a headless browser has a viewport but never scrolls. Everything below the fold stays outside the viewport for the entire render, so those images are never requested, and the PDF captures empty space where they should be.
Both the native attribute and JavaScript libraries do this:
<!-- native lazy loading: below-the-fold images never load in a headless render -->
<img src="photo.jpg" loading="lazy" alt="Product photo">
<!-- library-based: the real URL is in a data attribute until JS swaps it in -->
<img data-src="photo.jpg" class="lazyload" alt="Product photo">
There are three practical fixes, in order of reliability. The cleanest is to disable lazy loading in your PDF layout entirely, since a document has no scroll performance to optimize: render loading="eager", or omit the attribute. If you cannot change the markup, scroll the page programmatically before printing, so every image passes through the viewport and gets requested. If you drive the browser through an API, a full-page or scroll option does the same thing without you writing the loop.
// Puppeteer: force every lazy image to load before printing
await page.evaluate(async () => {
await new Promise((resolve) => {
let y = 0;
const step = setInterval(() => {
window.scrollBy(0, 400);
y += 400;
if (y >= document.body.scrollHeight) { clearInterval(step); resolve(); }
}, 60);
});
window.scrollTo(0, 0);
});
await page.pdf({ format: "Letter", printBackground: true });
The scroll approach works, but treat it as the fallback. It adds seconds to every render, and on a long document with hundreds of images it adds a lot of them. Turning lazy loading off in the print layout costs one line and no time.
Reason four: the render fired before the images downloaded
The last cause is a plain race. Images are external files fetched after the HTML parses, and if the renderer prints as soon as the DOM is ready, large files may still be arriving. The symptom is intermittency: the same document renders correctly most of the time and comes back with a missing photo occasionally, usually the biggest one.
Waiting for the DOM is not enough. Wait for the load event, which fires only after images and other subresources have finished, or better, wait for network activity to settle.
// waits for images and subresources, not just the DOM
await page.goto(url, { waitUntil: "networkidle0" });
await page.pdf({ format: "Letter", printBackground: true });
One caveat on networkidle0: on a page with polling, analytics beacons or a live connection, the network never goes quiet and the wait times out instead. On those pages, wait for a specific marker element that your template renders once everything is in place. That approach, and why it beats fixed delays, is covered in the guide to waiting for content before rendering. If your documents are large enough that these waits are pushing into timeouts, the large page timeout guide deals with that side of it.
Matching the symptom to the cause
| What you see | Most likely cause | Fix |
|---|---|---|
| Every image blank, including the header logo | Relative paths, unresolvable | Absolute HTTPS URLs, or base64 for small assets |
| Top images fine, everything below blank | Lazy loading | Disable lazy loading in the PDF layout |
| Loads in your browser, blank in the render | Auth, private host or expired signed URL | Test in a private window; sign at render time |
| Usually fine, occasionally one missing | Render fired before download finished | Wait for load or network idle |
| Images render but background images do not | Background printing disabled | Enable the print background option |
That final row is worth separating out, because a CSS background-image is not an <img> and browsers treat them differently when printing. Backgrounds are omitted by default to save ink, so a hero image or a patterned header set in CSS will vanish even when every real image tag renders perfectly. The background not printing guide covers it, and the short version is that you need to turn background printing on explicitly.
Two habits that prevent most of this
First, build a dedicated layout for documents rather than reusing the web page layout. A PDF layout should use absolute asset URLs, no lazy loading, no analytics scripts, and no print stylesheet you have not read. That one change removes three of the four causes above before they can happen.
Second, test the render from outside your network. Most image problems are reachability problems, and they are invisible from a machine that is logged in and on the VPN. A render that runs from somewhere else surfaces them immediately, which is a strong argument for testing against the environment that will actually do the work in production. This matters most for documents where the imagery is the point, like a proof of the on-brand images produced for a campaign, where a blank rectangle is not a cosmetic issue but a broken deliverable.
If you would rather not maintain the browser doing all this, the PDF generator API renders in managed Chromium with the waits and options above exposed as parameters, and the converter at the top of this page will run any URL or HTML string through it. If the images are landing but the styling around them is not, the CSS not working guide is the companion piece to this one.
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.