Guides · debugging
Your images downloaded fine and the PDF still has none of them — seven measured causes, and the check that names yours
Published 2026-08-17 · every number below was measured on the day of writing with Chromium 151.0.7922.34, driven by Playwright 1.62.1 and by puppeteer-core 25.8.0 against that same browser binary. The fixtures and commands are in the article so you can repeat them.
The frustrating part of this bug is that almost every instrument you reach for says the page
is fine. The server logged a 200. The screenshot looks right. Open the same URL in
a browser and there is the image. Then page.pdf() hands you a document with a gap where
it should be — or, in the worst version, one entirely blank sheet.
That is because "the image is missing" is at least seven different faults wearing the same coat, and three of them happen after a completely successful download. Below is each one isolated, with the measurement that separates it from its neighbours. Start with the snippet — it will usually tell you which section to read.
Run this first
This is the whole triage, and it takes about thirty seconds. The important part is the first line: you have to ask the page these questions under print emulation, because several of the causes below do not exist until Chromium switches media.
// Run this against the page you are about to print, under print emulation.
// It names the cause instead of making you guess. Playwright:
await page.emulateMedia({ media: 'print' }); // puppeteer: page.emulateMediaType('print')
console.log(await page.evaluate(() => {
const out = { broken: [], hiddenInPrint: [], pendingFonts: [], lazyPending: 0 };
for (const img of document.images) {
const src = img.currentSrc || img.src || '(no src)';
if (!img.complete || img.naturalWidth === 0) { out.broken.push(src); continue; }
for (let el = img; el instanceof Element; el = el.parentElement) {
const s = getComputedStyle(el);
if (s.display === 'none' || s.visibility === 'hidden' || s.opacity === '0') {
out.hiddenInPrint.push(src); break;
}
}
if (img.loading === 'lazy' && img.naturalWidth === 0) out.lazyPending++;
}
for (const f of document.fonts) if (f.status !== 'loaded') out.pendingFonts.push(f.family);
return { ...out, fontsStatus: document.fonts.status };
}));
// broken[] -> a 404, a 403, or a blocked request (causes 5 and 6)
// hiddenInPrint[] -> your print stylesheet removed it (cause 4)
// pendingFonts[] -> you printed before the font arrived (cause 7)
// lazyPending -> loading="lazy" never triggered (cause 3)
// all empty, still missing? -> it is a CSS background (cause 1)
Against four deliberately broken pages it separates them cleanly:
# the same snippet against four deliberately broken pages:
healthy broken=0 hiddenInPrint=0 fonts.status=loaded pendingFonts=[]
404 image broken=1 hiddenInPrint=0 fonts.status=loaded pendingFonts=[]
hidden by print CSS broken=0 hiddenInPrint=1 fonts.status=loaded pendingFonts=[]
font still loading broken=0 hiddenInPrint=0 fonts.status=loading pendingFonts=["P"]
The sixty-second version
| What you see | Cause | Fix |
|---|---|---|
Background colours and background images gone, <img> tags fine |
1. printBackground defaults to false and gates the CSS
background-* properties |
{ printBackground: true } |
| Nothing at all rendered; the call returned suspiciously fast | 2. waitUntil: 'domcontentloaded' returns before any image is fetched |
'load', or wait on the images explicitly |
| Images near the bottom missing; the ones at the top are fine | 3. loading="lazy" — the browser never scrolled, so it never asked |
Scroll the page first, or use a tall viewport, or strip the attribute |
Server logged a clean 200, image still absent |
4. A print stylesheet hides it. page.pdf() renders with
media: print |
Fix the print CSS, or emulateMedia({ media: 'screen' }) |
| Small identical images appear where yours should be | 5. Broken-image icons — the request failed | The broken[] list above has the URLs |
Works in your browser, 403 in the render |
6. The render has no session; your browser did | context.addCookies() before the navigation |
| Text is there but in the wrong typeface; layout shifted | 7. The webfont did not arrive — CORS, or you printed too early | document.fonts.check(), and note the setContent trap below |
1. printBackground, and exactly what it gates
Both libraries default printBackground to false, which everybody
discovers eventually. What is worth pinning down is the boundary, because "backgrounds are off"
is vaguer than the actual rule. One paint source per document, ink counted at 72 dpi:
One paint source per document, so the ink is attributable. A4, Chromium
151.0.7922.34 / Playwright 1.62.1. 'ink' = non-white pixels at 72 dpi;
'imgXObj' = embedded image objects reported by `pdfimages -list`.
case printBackground:false printBackground:true
--------------------------------------------------------------------------
<img src> ink= 900 imgXObj=1 ink= 900 imgXObj=1
CSS background-image ink= 0 imgXObj=0 ink= 900 imgXObj=1 <--
CSS background-color ink= 0 imgXObj=0 ink= 2250 imgXObj=0 <--
CSS gradient background ink= 0 imgXObj=0 ink= 2250 imgXObj=0 <--
text colour ink= 948 imgXObj=0 ink= 948 imgXObj=0
border colour ink= 1575 imgXObj=0 ink= 1575 imgXObj=0
inline <svg> fill ink= 1350 imgXObj=0 ink= 1350 imgXObj=0
<img> pointing at an SVG ink= 1350 imgXObj=0 ink= 1350 imgXObj=0
box-shadow ink= 1184 imgXObj=0 ink= 1184 imgXObj=0
canvas drawn by JS ink= 1350 imgXObj=1 ink= 1350 imgXObj=1
Three rows move. They are exactly the three that paint via a CSS
`background-*` property. Nothing else on the page cares about the option.
The rule is narrow and easy to hold in your head: printBackground gates the
CSS background-* properties and nothing else. Your
<img> tags, your text colours, your borders, your inline SVG, your box-shadows
and your canvas are all unaffected by it. So if the thing you are missing is an
<img>, this section is not your bug and no amount of toggling the option will
help — skip to cause 2.
The corollary is the one that catches people going the other way. A page that carries the standard "make my backgrounds print" declaration overrides the option completely:
# same document, printBackground varied, print-color-adjust:exact in the page CSS
$ md5 -q pb-false-with-exact.pdf pb-true-with-exact.pdf
654e605fd4830a50ad5d65a3a7ae65ae 8103 bytes
654e605fd4830a50ad5d65a3a7ae65ae 8103 bytes
# byte-identical. Once the page asks for exact colour, printBackground:false
# is not a smaller render — it is not a different render at all.
With print-color-adjust: exact in the page's own CSS, the two renders are the same
file — same checksum, same 8103 bytes. If you run a rendering service and treat
printBackground: false as a way to keep ink down or output small, a customer's
stylesheet can quietly opt out of that, and there is no signal anywhere that it happened.
print-color-adjust as telling the user agent that the content's
colours are deliberate and "should not be changed except by the user's request", and notes
that when printing, "dark or extremely dense background images might be removed" to save
ink. The property is the page author's way of declining that optimisation, and Chromium
honours it over the API option.2. You printed before the page finished
The single most common version of "blank PDF" is not a rendering fault at all. Two images, one instant and one held for 1200 ms:
Two images: one served instantly, one whose response is held for 1200 ms.
page.setContent returned images in the PDF
--------------------------------------------------------------------------
waitUntil: 'domcontentloaded' 2 ms none at all
waitUntil: 'load' 1209 ms both
waitUntil: 'networkidle' 1712 ms both
domcontentloaded + await the images 1210 ms both
'domcontentloaded' does not lose the slow image. It loses BOTH of them.
'networkidle' costs ~500 ms more than 'load' for the same output here —
that is its idle window, paid on every render.
Note what the first row actually does. 'domcontentloaded' is not "the fast one that
might miss slow images" — it returned in 2 ms and produced a PDF with
neither image, including the one the server answered instantly. It fires when the HTML
is parsed, and images are not part of that promise.
'load' is the right default here and it is what most people want. The interesting
row is the last one: waiting on the images yourself costs the same wall-clock as
'load', and unlike 'networkidle' it does not pay for an idle window, and
unlike a fixed waitForTimeout(3000) it is neither a guess nor a tax:
// what the last row of that table does — no arbitrary sleep, no idle window
await page.setContent(html, { waitUntil: 'domcontentloaded' });
await page.evaluate(() => Promise.all(
Array.from(document.images)
.filter((i) => !i.complete)
.map((i) => new Promise((r) => { i.onload = i.onerror = r; }))
));
// then, if you use web fonts:
await page.evaluate(() => document.fonts.ready);
'networkidle' is convenient and it is what we reach for in throwaway scripts, but
be clear about what you are buying. It cost 503 ms more than 'load' for
byte-identical output here, on every render — and that number is not a coincidence:
Playwright defines the state as "no network connections for at least 500 ms",
and labels the option DISCOURAGED in its own reference. As the next section
shows, it also does not guarantee that every image was even requested.3. loading="lazy" — the browser never scrolled, so it never asked
This one is the modern replacement for the old "images below the fold are missing" problem, and it behaves worse than the folklore suggests. Six images at known depths:
Six images. 41 eager top-of-page, 42 lazy top-of-page, 43 eager below a
spacer, 44 lazy below the spacer, 45 inside content-visibility:auto,
46 inside a div hidden by @media print. Default 1280x720 viewport.
spacer waitUntil fetched from the server present in the PDF
--------------------------------------------------------------------------
200px load 41,43,45,46 41,43,45
200px networkidle 41,42,43,44,45,46 41,42,43,44,45
6000px load 41,42,43,45,46 41,43,45
6000px networkidle 41,42,43,45,46 41,42,43,45
6000px load + scroll to bottom
41,42,43,44,45,46 41,42,43,44,45
Row 1: image 42 is lazy and at the TOP of the page — above the fold, in the
viewport — and 'load' still returned without it.
Row 4: 'networkidle' did not rescue image 44. It was never requested, so
there was never any network activity to wait for.
Every row drops 46: hidden by print CSS, fetched anyway. See cause 4.
Two rows deserve stopping on. In row 1 the lazy image is at the top of the
document, comfortably inside a 1280×720 viewport, and waitUntil: 'load' still returned
without it — which is exactly what the HTML specification asks for. MDN puts it plainly: the
load event "fires when the eagerly-loaded content has all been loaded", and at that moment
"it's entirely possible (or even likely) that there may be lazily-loaded images … within the
visual viewport that haven't yet loaded". Above the fold is not a defence.
Row 4 kills the usual workaround. With the image 6000 px down,
'networkidle' did not save it either — the request was never made, so there was no
network activity for "idle" to wait on. An option that waits for quiet cannot wait for a
request nobody sent.
Two things do work. Scrolling the page before you print gets everything, and so does simply giving the browser a viewport tall enough that the images are within lazy-loading range:
Same document, waitUntil held at 'networkidle', only the viewport changed:
viewport 1280x720 fetched=[41,42] in the PDF=[41,42]
viewport 1280x8000 fetched=[41,42,44] in the PDF=[41,42,44]
The tall viewport puts image 44 inside the lazy-loading trigger distance,
so it is requested, so networkidle waits for it.
We measured the viewport twice, because the first run held waitUntil at
'load' and showed no effect at all — which would have been a tidy, quotable and wrong
conclusion. With the timing variable removed the viewport clearly does matter. If you take one
method from this page, take that one: change a single variable per run.
// the belt-and-braces version, for documents of unknown length:
await page.evaluate(async () => {
for (const img of document.querySelectorAll('img[loading="lazy"]')) {
img.loading = 'eager';
}
window.scrollTo(0, document.body.scrollHeight);
window.scrollTo(0, 0);
});
await page.evaluate(() => Promise.all(Array.from(document.images)
.filter((i) => !i.complete).map((i) => i.decode().catch(() => {}))));
One hypothesis we expected to confirm and did not: CSS content-visibility: auto
caused no trouble at all. An image inside a content-visibility: auto container was
present in every single run above. Chromium lays the document out for print before it paints,
and that pass renders skipped subtrees. Cross it off the list.
4. The request succeeded and the image still is not printed
This is the cause that survives every check you are likely to run, because the download
genuinely happened. page.pdf() does not render the page you have been looking at — it
renders the page with the print media type applied:
Three images: 51 shown only inside @media screen, 52 hidden inside
@media print, 53 unconditional.
requested by the browser present in the PDF
--------------------------------------------------------------------------
page.pdf() as-is 51, 52, 53 53
after emulateMedia 51, 52, 53 51, 52, 53
({ media: 'screen' })
All three are fetched in both cases. Your network log, your access log and
your CDN dashboard all show a healthy 200 for an image that is not in the
PDF. That is why this one costs people an afternoon.
Both hidden images were fetched. Both are absent from the PDF. If you are debugging from
access logs, a network panel, or a CDN dashboard, all three tell you the image is fine, and
they are all correct — the fetch was never the problem. Somewhere in your CSS, or your
framework's, or a component library's, there is a
@media print { … display: none } block, or an element that only becomes visible
inside @media screen.
What makes this genuinely hard is that you cannot see it from inside the page:
await page.evaluate(() => matchMedia('print').matches) // -> false
await page.pdf({ ... }) // renders as print anyway
await page.emulateMedia({ media: 'print' });
await page.evaluate(() => matchMedia('print').matches) // -> true
The print media that page.pdf() uses is applied inside the call. Anything
you inspect before it — in evaluate(), in a screenshot, in a headed browser
— is still the screen rendering. Emulate first and the two agree.
Until you emulate, the document you are inspecting is the screen rendering, and the print
rendering exists only for the duration of the pdf() call. That is why the diagnostic
at the top of this page emulates first. Once you do, getComputedStyle tells you the
truth and the offending rule is one search away.
Playwright documents the behaviour directly — page.pdf() "generates a pdf of the
page with print css media. To generate a pdf with screen media, call
page.emulateMedia() before calling page.pdf()". Puppeteer's
PDFOptions reference does not mention media emulation at all, which is a fair part of
why this is asked more often about Puppeteer.
5. Broken images have a fingerprint
When a request fails, Chromium does not leave a hole. It draws its broken-image icon, and that icon lands in the PDF as real embedded images:
$ pdfimages -list out.pdf
page num type width height color comp bpc enc interp ...
1 0 image 14 16 rgb 3 8 jpeg no ...
1 1 image 14 16 rgb 3 8 jpeg no ...
# Two 14x16 images on a page whose only <img> is 90x90. That pair IS the
# broken-image icon — one pair per failed image. Measured: one broken
# <img> -> two 14x16 XObjects; two broken <img> -> four.
#
# So 'pdfimages found images' does not mean your images arrived, and
# 'pdfimages found nothing' does not mean they did not: an <img> pointing
# at an SVG draws as vectors and produces no XObject at all.
A pair of 14×16 objects per failed image is a reliable tell, and it is worth knowing in both
directions, because pdfimages -list is not the oracle it looks like. Images can be
present in the file and not be yours; and an <img> pointing at an SVG paints as
vector drawing operations, contributing ink to the page but zero image
XObjects. We measured both. Judge the page by ink and by
document.images, not by the XObject count.
6. Your browser has a session; the renderer does not
Nearly every report that begins "it works when I open the URL myself" is this. Your browser has been logged in for weeks. The automation launches a clean profile:
An <img> whose bytes require a session cookie, page loaded with goto():
responses imgXObjects
--------------------------------------------------------------------------
no cookie 200 /page.html 403 /auth.png 14x16 14x16 (the icon)
context.addCookies() 200 /page.html 200 /auth.png 90x90 (the image)
The fix is to give the context the credentials before the navigation, not the page after it:
// Playwright — put it on the context, before the navigation:
const ctx = await browser.newContext();
await ctx.addCookies([{ name: 'sess', value: TOKEN, url: 'https://app.example.com' }]);
const page = await ctx.newPage();
await page.goto('https://app.example.com/invoice/42', { waitUntil: 'load' });
// Puppeteer:
await browser.setCookie({ name: 'sess', value: TOKEN, domain: 'app.example.com' });
page.setContent() the document's location is about:blank and its origin is
literally null (we checked: location.href === 'about:blank',
String(origin) === 'null'). Cookies scoped to your real host are then cross-site for
every subresource, and default SameSite=Lax cookies are not sent. If you build HTML
in memory and it needs credentialed assets, navigate to the real origin first, then call
setContent — the page keeps the origin. The same trap has a sharper edge for fonts,
next.7. Fonts: two unrelated faults with one symptom
puppeteer pdf fonts not loading is really two bugs, and the fix for one does
nothing for the other. Same font file, same server, same driver — only the way the document was
loaded changes:
One @font-face, one 40px line of text. 'lastGlyph xMax' is the right edge
of the text run from `pdftotext -bbox` — the webfont here is roughly twice
the width of the fallback, so the number says which font actually drew.
how the page was loaded embedded font xMax fonts.check()
--------------------------------------------------------------------------
goto(), font same-origin Zapfino 316.65 true
setContent(), font WITHOUT CORS hdr Courier 168.03 FALSE
setContent(), font WITH CORS hdr Zapfino 316.65 true
goto() first, then setContent() Zapfino 316.65 true
Same font file, same server, same driver. The single variable is whether the
document had a real origin when it asked for the font.
Row 2 is the setContent trap again, and fonts feel it harder than images do:
fonts are subject to CORS and images are not. An
<img> from any origin renders happily in a null-origin document; a
@font-face from a different origin needs
Access-Control-Allow-Origin, does not get it, and silently falls back. Row 3 proves it
is CORS rather than the font file or the driver: add the header, same everything else, the font
loads.
And here is why this one eats an afternoon:
// the CORS row above, inspected in the page:
document.fonts.status // -> 'loaded' <-- resolved
await document.fonts.ready // -> resolves immediately
document.fonts.check('40px Probe') // -> false <-- never arrived
// fonts.ready means 'font loading has finished', not 'the fonts loaded'.
// A font that failed is finished. Check the one you need by name:
await page.evaluate(() => document.fonts.ready);
const ok = await page.evaluate(() => document.fonts.check('40px Probe'));
if (!ok) throw new Error('webfont did not load — check CORS and the URL');
document.fonts.ready resolved. document.fonts.status said
'loaded'. The font was not loaded. Both are telling the truth as specified —
font loading has finished — and a font whose request failed has finished. The standard
advice for this bug is "await document.fonts.ready", and against a CORS failure it is
a no-op that makes you confident. Assert on document.fonts.check() for the family you
actually need, or on the artefact with pdffonts out.pdf.
The second fault is ordinary timing, and there fonts.ready is exactly right:
Same page via goto(), same-origin font, response held for 1500 ms:
waitUntil: 'domcontentloaded' no font embedded at all, fonts.status=loading
waitUntil: 'domcontentloaded'
+ await document.fonts.ready Zapfino, xMax 316.65
waitUntil: 'load' Zapfino, xMax 316.65
waitUntil: 'networkidle' Zapfino, xMax 316.65
When the whole page is blank
Everything below produces a structurally valid, completely empty PDF —
pdftotext returns nothing and no ink lands:
Everything below returns a valid PDF. pdftotext gets nothing out of the
first three, and no ink lands on the page.
pages ink images text
--------------------------------------------------------------------------
@media print { body { display: none } } 1 0 - (empty)
content inside a height:0 wrapper 1 0 - (empty)
content written 900 ms after DOM ready, 1 0 - (empty)
printed at waitUntil:'load'
control (the same markup, printed sanely) 1 1669 1 HELLO
bonus, and not a bug: position:fixed content repeats on EVERY sheet —
a 3-page document printed the fixed header 3 times.
The third row is the one to check first if you render a single-page app: the framework
mounts after the HTML is parsed, 'load' does not wait for your JavaScript to finish
thinking, and you have printed an empty shell. Wait for something specific — a selector you
know only exists once the app has rendered — rather than a lifecycle event:
await page.waitForSelector('#report-ready').
The last row of that table is a habit worth keeping: always print the same markup through a known-good path first. If the control is blank too, the fault is in your call, not in the document.
Both libraries, same answer
Worth stating, because the search results for these symptoms mix the two libraries freely and it is fair to ask whether an answer transfers:
Playwright 1.62.1 vs puppeteer-core 25.8.0, launched against the same
Chromium 151.0.7922.34 binary via chromium.executablePath():
Playwright puppeteer-core
--------------------------------------------------------------------------
printBackground default [40] ink=900 [40] ink=900
printBackground: true [40,50] ink=4594 [40,50] ink=4594
@media print / screen [53] ink=1600 [53] ink=1600
Identical. Unlike the outline option, nothing here is a driver difference —
it is all Chromium's print pipeline, so an answer written for one library
transfers to the other.
It does. Every behaviour on this page is Chromium's print pipeline rather than a driver
decision, so a Puppeteer answer applies to Playwright and back. That is not true of everything
— outline and tagged genuinely differ between them, which is
its own article — but it is true
here.
If you would rather not own this
None of this is hard once you know it, and the knowledge does not expire quickly. Driving Chromium yourself costs an afternoon and then a helper function, and you keep full control of the viewport, the waiting strategy and the cookies — which, as the sections above show, is most of the fight.
If you would rather hand the render to a service, snapdok.io is one option
among several, and you should know what you are trading. We run Chromium through Playwright, so
causes 1, 2 and 3 are settled by our defaults: printBackground is on, we wait for the
page rather than for a lifecycle event, and web fonts get a real origin. Causes 4, 5, 6 and 7
are still yours — a @media print rule in your stylesheet will hide an element from our
render exactly as it does from your own, and a page behind a login is a page we cannot reach.
We do not expose a cookie or header parameter today, so authenticated pages are a genuine
reason to keep driving the browser yourself, and several other vendors do expose one. There is
a free tier of 250 renders a month without a card if you want to compare output against your
own pipeline before deciding anything.
The short version
printBackground gates the CSS background-* properties and nothing else, so
if a missing thing is an <img> the option is not your bug — and a page carrying
print-color-adjust: exact overrides the option to byte-identical output.
waitUntil: 'domcontentloaded' returns before any image is requested;
'load' is the sane default and 'networkidle' costs about 500 ms more for
the same bytes. loading="lazy" images are excluded from the load event even above the
fold, and 'networkidle' cannot rescue a request that was never made — scroll, or use a
tall viewport. page.pdf() renders with print media, so an image can be fetched with a
clean 200 and still be hidden by a stylesheet you have never read, and you cannot
see that without emulateMedia. Failed images leave a pair of 14×16 XObjects behind, so
the file is never quite empty. And document.fonts.ready will resolve happily for a
font that failed CORS — check document.fonts.check() instead, and remember that
setContent leaves you on a null origin where fonts need a CORS header and
images do not.
Related, from the same measurement pass:
why your PDF header is not showing —
five causes, including the 1 px default font inside header templates —
why landscape: true loses to
a line of CSS, and
why outline: true returns a
byte-identical file unless you also pass tagged: true.
Sources, all read 2026-08-17:
Playwright's
page.pdf() reference — the print media sentence and
emulateMedia instruction quoted above, printBackground "Defaults to
false", and the waitUntil table where 'networkidle' is marked
"DISCOURAGED" and defined as "no network connections for at least 500 ms" ·
Puppeteer's
PDFOptions reference — printBackground is "Set to true to
print background graphics", default false; there is no mention of print media
emulation on that page ·
MDN
on lazy loading — the load event "fires when the eagerly-loaded content has all been
loaded", with lazily-loaded images inside the viewport possibly still pending ·
MDN
on print-color-adjust — what exact asks the user agent to stop
doing. Every measurement is ours, taken on 2026-08-17 with Chromium 151.0.7922.34 via
Playwright 1.62.1 and via puppeteer-core 25.8.0 launched against the same binary, with
pdfimages -list, pdffonts, pdftotext -bbox and
non-white pixel counts from pdftoppm -r 72.