What actually happens between passing HTML and receiving a PDF? Many developers only care about the addPage and save calls and know nothing about the pipeline underneath — which is exactly why they struggle when long documents stutter, progress cannot be reported, or resource-timing bugs appear. The dompdf.js pipeline is cleanly layered: the main thread collects a DOM snapshot, a worker thread prepares render data, the Rust/WASM core writes the PDF bytes, and the browser finally returns a Blob or triggers a download. Understanding this pipeline explains why the page does not freeze during export and why long documents benefit from staged processing — and it lets you surface render progress to users through the officially documented onProgress callback. This guide walks through each stage of the render lifecycle, explains the progress event flow with working code, and lays out staged rendering strategies for long documents plus a practical checklist for the most common timing bugs. Along the way you will learn where the real performance bottlenecks hide, how to measure them, and why the timing of save matters more than the timing of addPage. We also show how progress events double as performance instrumentation, why idempotent exports prevent entire classes of timing bugs, and how staged rendering scales team collaboration on long documents.
Stage one is snapshot collection: the main thread walks the target DOM and records structure, styles, images, and font references into an independent snapshot. This stage reads a large number of nodes, so the more complex the template, the longer it takes — and because it runs on the main thread, the page may feel briefly busy. This is precisely why keeping templates lean matters: every extra node is extra snapshot work.
Stage two happens on a worker thread: the snapshot is encoded, pagination is computed, and render data is prepared. Because the main thread is not involved, the UI stays responsive. The fact that the page does not appear to freeze during export is a direct consequence of moving the heavy work off the main thread — the core architectural advantage of dompdf.js over screenshot-based approaches, and the difference is dramatic for long documents.
Stage three is taken over by the Rust/WASM core: page by page, the layout results are written into the PDF byte stream, including text glyphs, image data, compression, and page structure. WASM executes far more efficiently than pure JavaScript doing pixel-by-pixel work, so write speed for long documents is a clear win, and the output files are smaller as well. This is the engine behind dompdf.js's performance reputation.
Stage four wraps up: the PDF bytes are packaged into a Blob, handed to save for download, or processed further by the caller. The full lifecycle runs from content collection in addPage to the completed download in save, with each intermediate stage exposing state through progress events for the UI layer to consume. Keeping this main line in mind gives every debugging and optimization effort a clear direction.
One more thing worth internalizing: the pipeline is designed so that the expensive stages never block interaction. If your export feels slow, the bottleneck is almost always snapshot size or image payload, not the WASM write itself — and knowing where to look saves hours of profiling.
Knowing the four stages also tells you where to optimize. Slow export? First determine whether snapshot collection is the bottleneck (complex template) or the write phase is (large content volume), then act accordingly — simplify the template in the first case, split the content in the second. Optimizing in the right direction beats blind parameter tweaking every time.
The official documentation records an onProgress callback that exposes the render process stage by stage. The callback receives a progress object containing a stage field plus stage-relevant data: the page-counting stage reports the total page count, and the rendering stage reports the current page and the total, which the UI layer can turn into a progress bar or percentage. The feedback is concrete and directly actionable.
The callback's value shows up most clearly with long documents: rendering dozens of pages takes seconds, and without feedback users assume the page has frozen. With stage plus current page and total, you can display concrete progress such as 'rendering page 3 of 20'. The waiting experience is completely different, tolerance for export latency rises noticeably, and the duplicate clicks and support complaints that accompany opaque waits largely disappear.
Note what the callback does not do: it reports progress only and handles no errors. Render failures still have to be caught by the caller through the exception mechanism. Progress events and error handling each own one side of the feedback story, and only together do they form a complete export feedback system. Missing either one leaves a visible hole in the user experience, so design both in from the start.
A useful pattern is to map stage transitions to UI states: idle before export, 'collecting' during snapshot and counting, 'rendering X of Y' during the write phase, and done or error after save resolves. The progress object gives you everything needed to drive that state machine, and the UI code stays simple because the library does the stage bookkeeping.
The callback is also a free performance instrumentation point: log the duration of each stage and watch the averages and variance over time. If render-stage time climbs noticeably after an upgrade, the logs expose the regression immediately — long before users complain — which is the hidden value of progress events that few teams exploit.
This example shows the class-based flow wrapped in an async function with UI state transitions, followed by the officially documented onProgress callback in its functional form, which reports the total page count and per-page render progress. The two together cover the whole lifecycle from UI feedback to granular progress.
import { DomPDF } from 'dompdf.js';
// Class API main flow: construct -> collect content -> render and output
async function exportReport(container) {
const pdf = new DomPDF({ format: 'A4', margin: '20mm' });
pdf.addPage(container);
showProgress(true); // show progress UI before rendering
try {
pdf.save('annual-report.pdf'); // triggers render and download
showProgress(false);
notifySuccess('Export complete');
} catch (err) {
showProgress(false);
notifyError('Export failed, please retry');
}
}
// Officially documented onProgress callback (functional API form)
// Use it to obtain the total page count and per-page render progress
import dompdf from 'dompdf.js';
const blob = await dompdf(document.querySelector('#capture'), {
format: 'a4',
pagination: true,
onProgress(progress) {
if (progress.stage === 'countingPages' && progress.totalPages) {
console.log(`Total pages: ${progress.totalPages}`);
}
if (progress.stage === 'rendering' && progress.currentPage) {
console.log(`Rendering page ${progress.currentPage}/${progress.totalPages}`);
}
},
});
With the class API — new DomPDF(), addPage, save — the lifecycle follows the same collect, render, output order, only the entry points are more concise: constructing the instance initializes the default configuration, addPage collects content, and save triggers rendering and output. At the code level, the 'events' are the call order and the async boundaries, and calling the methods in order yields correct results by construction.
In practice, wrap the export flow in an async function and manage UI state around addPage and save: disable the export button and show a loading indicator before, restore the button and show completion after. Catch exceptions with try/catch and funnel both render failures and download failures into one error-notification path, so callers never have to understand internal details. The wrapper becomes the single place where the feature's contract with the UI lives.
If you need finer-grained stage information — page counts, render progress — combine the officially documented progress callback with your own state management: the callback handles real-time feedback during rendering, the outer async flow owns the overall success and failure semantics. The two layers together cover the complete lifecycle, keep code structure clean, and keep responsibilities clearly separated.
A subtle benefit of the wrapper approach: every export path in the app — single export, batch export, modal-triggered export — can share one lifecycle implementation. UI state, error handling, and logging all stay in one place, and new export features inherit the behavior for free rather than re-implementing it with subtle differences.
One caution about the class flow: because save triggers both rendering and download, the moment between calling save and it resolving is where rendering actually happens. If you measure export time, measure around save — before it for start time, after it for completion — and you will capture the true cost, which addPage alone never reveals. A related timing nuance: addPage collects the DOM at call time, so if the page updates between addPage and save, the rendered output reflects the state at save time — which explains the occasional 'stale content' exports after data changes.
And because the wrapper owns the lifecycle contract, all export entry points — the single button, the batch runner, the modal flow — share one implementation of UI state, error handling, and logging. New export features inherit the behavior instead of re-implementing it with subtle differences, which is exactly the consistency users experience as reliability.
For very long documents (dozens or hundreds of pages), cramming everything into one addPage call puts heavy pressure on snapshot collection and pagination computation. The recommended approach is to split the document into chapters with multiple addPage calls: each call carries less content, render data stays compact, and progress feedback can be reported at chapter granularity. Both user experience and maintainability win.
Splitting has a second benefit: when something fails, the search space is small. If a specific chapter breaks rendering, you inspect that chapter's template and data rather than hunting through an entire long document. Chapter templates can also be tested independently — render each one alone to verify it, then assemble the full document. Problems surface early, when they are cheap to fix, and delivery quality improves accordingly.
For content that genuinely must render in one shot, measure the real cost first: record the time from addPage to save completion and check whether it grows linearly with content volume. If the time exceeds what users will tolerate, consider splitting or telling users to export in batches. Either beats the alternative — users waiting through a long, opaque render only to discover the result is wrong.
A useful rule of thumb: if a document takes more than a few seconds to export, it deserves staged rendering and progress feedback; if it takes tens of seconds, it deserves a dedicated UX plan (progress bar, cancel option, or background generation). Matching the experience to the duration is what separates a professional export feature from a prototype.
Finally, treat the chapter boundaries as product decisions, not implementation details. Chapters are natural places for headers, page resets, and per-chapter styling — aligning the technical split with the document's logical structure gives you both performance and design fidelity, and readers perceive the result as intentionally structured.
Staged rendering also scales the team: each chapter template can be owned and tested by a different member, with a single assembly function composing them in order at the end. With clear ownership boundaries, parallel development on long-document projects becomes faster and version conflicts shrink dramatically — a structural benefit that compounds as the document set grows.
Three timing bugs account for most export problems. First, images exported before they finish loading, producing PDFs with missing pictures. Second, dynamic data that is not ready when rendering fires, producing blank content. Third, repeated clicks on the export button launching concurrent render tasks. The first two require waiting for resources before export; the third is best prevented by disabling the button during export so duplicate tasks never start.
When rendered output looks wrong, verify the input before suspecting the engine: print the HTML string or element outerHTML passed to addPage in the browser console and confirm the content is complete, styles are inline, and image URLs are valid. Only then move into render-stage investigation. This two-step isolation narrows the problem to one layer in minutes and prevents the classic failure mode of debugging the wrong half of the stack.
Finally, log the export flow thoroughly: start time, page count, render duration, and failure reason. When a production issue appears, these logs reconstruct the scene directly; with version numbers attached, they also reveal whether an upgrade introduced the regression. Logging is one of the highest-leverage habits for any export feature, and the payoff only grows as the codebase ages.
And one habit that prevents entire classes of timing bugs: make export idempotent. If the same state can be exported twice without side effects, users can retry freely after failures, and you can reproduce issues locally without fear of corrupting state. Idempotency turns 'the export occasionally fails' from a scary production incident into a self-healing user flow.
Finally, use the browser developer tools deliberately: pause execution before export to inspect the DOM snapshot state, or invoke the render function manually and inspect the return value. Timing problems that look like black magic usually resolve in minutes once you watch the pipeline from the tools — the discipline of verifying state at each stage beats guessing at symptoms.
下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:
这是由 dompdf.js 渲染的示例 PDF 内容。