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

MDN describes 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' });
A related trap that cost us a measurement while writing this: with 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 referenceprintBackground 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.