← dompdf.js Studio

Lazy-Loaded Images and Async Handling in dompdf.js

Lazy loading is the default performance optimization of the modern web: images download only as they approach the viewport, so the first screen renders faster and bandwidth is saved. But lazy loading is in direct tension with PDF generation. When the user triggers an export, images below the fold may never have been requested at all, so the template renders with large blank regions and the delivered PDF does not match what the page looked like. dompdf.js renders the real DOM, which means the loading state of every resource is whatever the page happens to have at snapshot time; the developer must actively manage image loading order, either by forcing all images to load before generation or by waiting for decoding to finish before rendering starts. This guide examines the root cause of the conflict between lazy loading and PDF output, the traps of the loading attribute and IntersectionObserver, the correct way to wait for image decoding with decode(), the implementation of batch preloading with progress feedback, and the retry and error-handling patterns that keep exports reliable. By the end you will have a repeatable resource-readiness gate that makes every image in every export appear exactly when it should, regardless of how aggressively the page lazy-loads. The gate pattern also composes with the other resource-readiness checks in dompdf.js workflows — fonts via document.fonts.ready and CSS backgrounds via preloaded images — so one unified readiness phase covers every asset type in the template. The closing best-practice section turns all of it into a checklist your team can adopt without debate.

Why Lazy Loading Leaves PDFs Without Images

The essence of lazy loading is delay: an image initiates its network request only when it approaches the viewport. That is a win for page display, but when the user clicks export, a large share of the images in a long page may never have been requested, so the DOM holds only placeholders or empty shells. When dompdf.js snapshots that DOM, it can only render what exists, and the resulting PDF contains blank regions where the images should be. This is the single most common reason lazy-loaded pages export incomplete documents.

Even when images have started loading, there is a race between the asynchronous network fetch and the snapshot: if the snapshot happens before decoding completes, the pixel data is still unavailable. Lazy loading amplifies this race dramatically — on a normal page the images were ready long ago, but on a lazy-loaded page only the first-screen images are typically usable at export time, and the rest are in flight or not started. The failure is also intermittent, which makes it especially frustrating to reproduce and fix.

Once the root cause is clear, the direction of the solution follows: force every image used by the template to finish loading before export. However the page behaves during browsing, the export flow must run a complete resource-readiness gate so that all images are readable at snapshot time. That principle — no snapshot before resources are ready — is the foundation every other technique in this guide builds on.

The intermittency also matters for testing: a race condition that only fails on slow networks is the kind of bug that ships to production unnoticed. Reproduce it deliberately by throttling the network in the developer tools, confirm the gate catches it, and then run the export again under a fast connection to see the fix hold in both worlds.

The Traps of loading=lazy and IntersectionObserver

The loading=lazy attribute and IntersectionObserver are the two dominant lazy-loading implementations: the former is handled by the browser, the latter by application code. Their shared behavior is that images download only when they enter the viewport. During PDF export, images in the template that were never observed or never entered the viewport remain in the unloaded state, and rendering them directly produces missing images. Any preload strategy therefore has to account for the page's actual lazy-loading behavior rather than assuming the DOM reflects fully loaded content.

A second, easily missed detail is container visibility: when the template lives inside an invisible container or an iframe, IntersectionObserver may conclude that every image is outside the viewport and load none of them. When troubleshooting, first confirm how the template is rendered and whether the images issue any network requests at all — the network panel in the developer tools answers this in seconds and prevents long detours through the wrong layer of the stack.

The conclusion is not to ban lazy loading but to confine it to the browsing experience: when a template is used for PDF generation, it goes through the preload flow instead. Decoupling the template from the page — the page lazy-loads as it likes, and exports use a separate, fully eager template — is the cleanest engineering answer, because the two paths no longer interfere with each other and each can be optimized independently without breaking the other.

There is one more variant worth knowing: some implementations combine lazy loading with responsive images, where the srcset candidate is chosen only when the image approaches the viewport. The export gate must then resolve the final source before waiting, or the decode may complete for the wrong candidate. Normalizing the image sources before the gate avoids this class of subtle mismatch.

Code Example: Wait for All Images to Decode Before Generating

A standard resource-readiness gate has two layers: make sure every image has finished loading and decoding, then call addPage. Image elements expose a decode() method whose returned Promise resolves when the image is ready to draw, and combining it with Promise.all waits for the whole set at once. This is cleaner and more reliable than listening for individual load events, because it maps directly onto what the render pipeline actually needs: decoded pixels, not merely a downloaded file.

If the template contains both img elements and CSS background images, iterating over img tags alone is not enough; background resources also need their loading state checked. A background image can be preloaded with an Image object using the same URL, or converted to a data URL and placed directly into the stylesheet so the template becomes self-contained and its loading state is entirely under your control. Both approaches keep the logic simple and the failure surface small.

The code below collects every image in the template, waits for all of them to decode, and only then hands the container to dompdf.js: querySelectorAll gathers the images, Promise.all waits for decode across the set, and the DomPDF instance is created afterward. A failed decode is caught and logged rather than allowed to abort the whole export, so one broken image degrades gracefully instead of blocking the document. This same gate works for any root element, whether it is a detached container or the live page.

Note that decode() throws when the image fails to load, so the catch is not optional decoration — it is the mechanism that turns a hard failure into a logged, recoverable one. Pair the gate with a timeout if the template can contain many images: a stuck request should not hold the export hostage forever, and the timeout can trigger the fallback path with placeholder styling.

import { DomPDF } from 'dompdf.js';

async function waitForImages(root) {
  const images = Array.from(root.querySelectorAll('img'));
  await Promise.all(images.map(img => {
    if (img.complete && img.naturalWidth > 0) return Promise.resolve();
    return img.decode().catch(() => {});
  }));
}

const html = `
  <style>body { font-family: 'Source Han Sans SC', sans-serif; }</style>
  <h2>Product Gallery</h2>
  <img src="images/p1.jpg" alt="Product one">
  <img src="images/p2.jpg" alt="Product two">`;

const container = document.createElement('div');
container.innerHTML = html;
document.body.appendChild(container);

await waitForImages(container);

const pdf = new DomPDF({ format: 'A4', margin: '20mm' });
pdf.addPage(container, { format: 'A4' });
pdf.save('gallery.pdf');

Batch Preloading with Progress Feedback

Waiting for a large number of images at once confronts the user with an unresponsive-feeling wait, which is a poor experience for something the user explicitly requested. The engineering answer is to break the export into visible steps: count the images first, preload them in batches, update a progress indicator as each batch completes, and only then enter the rendering phase. The user always sees forward motion, and a long task stops feeling like a hang.

Progress feedback is straightforward to build: track the number loaded against the total and update the UI in a callback; optionally split the flow into a loading phase and a rendering phase, each with its own progress display. Because dompdf.js addPage returns a Promise, page-by-page rendering can advance the same progress UI, so the loading and rendering stages share one consistent presentation with no duplicated infrastructure.

The batch size needs a deliberate trade-off: loading everything at once spikes memory, while loading one by one is slow. A practical default is ten to twenty images per batch, which bounds instantaneous memory without adding so many round trips that the overall time suffers. For exceptionally large images, yield the main thread between batches so the page stays interactive and the user can cancel or retry rather than waiting helplessly.

Progress feedback also helps operations: when exports run unattended or in bulk, the same counters can feed a log line per batch and a summary at the end, so an operator sees exactly how many images loaded, how many failed, and how long each phase took. That transforms an opaque long-running task into an observable one, which makes capacity planning and incident response dramatically easier, and lets you spot regressions in image-serving performance before users complain about slow exports.

Placeholders, Error Handling, and Retries

Lazy-loaded pages commonly contain placeholder images and failure images: a placeholder is a real resource that gets replaced once the true image arrives, while a failure image is the broken-image icon substituted by an onerror handler. PDF generation must treat the two differently — placeholders should wait for the real image to load, and failed images should be retried or replaced with a styled fallback. Shipping a broken-image icon into a delivered document is never acceptable, so this distinction is not a nicety but a quality gate.

Error handling should include a retry mechanism: after a failed load, retry once or twice with a short delay, which meaningfully raises the success rate in flaky-network conditions. If retries still fail, log the image and substitute a placeholder style in the template so the PDF keeps a complete layout instead of showing a torn region. The content remains usable in every case, and the failure is visible in logs for follow-up rather than in the customer's document.

Retries need concurrency and deadline discipline: retrying many images at once can saturate the network again, so cap the concurrency, and give the whole preload phase a timeout after which failures are accepted and rendering proceeds. Wrapping retry, concurrency, and timeout into one shared preload utility lets both the page and the export flow use identical, battle-tested logic, which keeps maintenance costs low and behavior consistent everywhere.

A refinement worth considering: distinguish transient failures from permanent ones. A 404 or an unsupported format will not succeed on retry, so retrying wastes time; network timeouts and 5xx responses, on the other hand, often clear on a second attempt. Classifying the failure before deciding whether to retry makes the mechanism faster and more honest about its limits.

Best Practices for Lazy-Loading Scenarios

The best practices for lazy-loading scenarios reduce to four rules: decouple the template from the page and use a dedicated template for exports; force-load and await decoding of all images before export; give the preload phase progress feedback, retries, and a timeout; and run a single resource-readiness gate before addPage. With all four in place, missing images essentially disappear, and exports become deterministic and reproducible instead of depending on how the page happened to be scrolled.

Two performance techniques round out the list. Parallel requests speed up preloading, but browsers cap concurrent connections per host, so add a concurrency limiter for large sets. After generation, release large image references and revoke Blob URLs promptly, because in repeated-export flows memory reclamation directly affects page stability; a page that exports many documents can otherwise degrade into jank and crashes over time.

Finally, turn the preload logic into a reusable function in your team's shared library rather than rewriting it in every project. Image loading problems are among the highest-frequency failure sources in PDF generation, and a standardized preload flow benefits the whole team: export quality stays stable, user complaints drop, and the long-term maintenance cost falls because the hardened logic lives in exactly one place that everyone already trusts.

The same discipline applies to the template itself: keep the export template free of lazy-loading attributes and viewport-dependent observers, because those mechanisms have no value in a detached rendering context and only add failure modes. A template that is deliberately eager — every image loading immediately when the template mounts — removes an entire category of export bugs before they can occur. It also makes the template's behavior identical in every environment, which is exactly the reproducibility PDF generation needs.

⚡ 现场演示(点击生成 PDF)

下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:

Hello from dompdf.js!

这是由 dompdf.js 渲染的示例 PDF 内容。