Exporting a PDF from the browser is genuinely heavy work: the DOM has to be walked, layout measured, fonts resolved, and a stream of PDF bytes written by a WebAssembly core. With a long document this easily occupies the main thread for hundreds of milliseconds or more, which users experience as a frozen page, an unresponsive button, or in the worst case the browser's page-unresponsive dialog. dompdf.js was designed around this problem from the start. The main thread only captures a structured snapshot of the DOM; the real rendering work is handed to a Web Worker, where the Rust/WASM core lays out pages and writes the PDF byte stream, and the finished result comes back as a Blob. The UI thread is never blocked by the expensive parts of the pipeline, so scrolling, typing, and animations keep running while the export proceeds in the background. This guide explains how that pipeline is organized, what each of the four stages — snapshot, transfer, render, and handback — actually does, and where a developer should hook in to customize behavior. It then walks through a complete example of wrapping dompdf.js inside a module worker, including a job-based message protocol, progress reporting, and error handling, and finishes with the practices that matter in production: batch queues, memory management, deployment notes, and a graceful fallback path when workers are unavailable. By the end you will be able to ship an export feature that never blocks the UI and degrades gracefully when something goes wrong. Because the pipeline is observable at each stage, teams can also measure where export time actually goes — snapshot capture, font loading, layout, or byte writing — and target optimizations at the true bottleneck instead of guessing.
dompdf.js organizes export into four distinct stages. In the first stage, the main thread walks the DOM and records a structured snapshot: the nodes that must appear in the PDF, their computed styles, and their geometric information. Nothing expensive happens here, which is deliberate — the snapshot phase is intentionally light so that the main thread's involvement stays cheap even for very large documents. The snapshot is then handed to a Web Worker through structured cloning, which deep-copies the data so the worker can work on it without touching the live page.
Inside the worker, the Rust/WASM core takes over: it measures layout, assigns content to pages, embeds fonts, and writes the PDF byte stream. This is where the seconds of work actually happen, and it is completely off the main thread. When rendering finishes, the worker posts the result back, and the main thread receives a Blob that can be used for download, preview, or upload. Each stage has a clear boundary, which is exactly what makes the pipeline testable and maintainable: you can instrument any stage without touching the others.
For a developer, the practical value of understanding this pipeline is knowing where to intervene. If you need to customize what gets captured, you hook into the snapshot stage on the main thread. If you need progress monitoring, you instrument the message layer. If you need parallel exports, you manage multiple worker instances. The pipeline itself is fixed, but every boundary is visible and documented, so integration cost stays low and problems can be traced to a specific stage instead of disappearing into a monolithic export routine.
This stage-based structure is what allows the library to report meaningful progress, isolate failures to a single phase, and be tested piece by piece — each stage has a defined input and output, which makes the whole pipeline debuggable and the behavior of every export predictable.
The browser's main thread juggles event handling, layout, painting, and JavaScript execution at the same time. Any single task that runs too long manifests as jank, and PDF export is exactly the kind of task that does. Initializing the WASM module alone costs tens of milliseconds; adding font parsing and full-document layout, a single export routinely exceeds several hundred milliseconds, which is already long enough for users to perceive the page as unresponsive and, on some machines, for the browser to offer to kill the page.
Once the work moves to a worker, the main thread's frame rate is no longer affected by export. Animations, input, and scrolling all keep running normally. For pages that combine preview with export — an editor with a download button, a form with a PDF receipt — users can keep interacting while the document renders. This kind of parallelism is simply impossible with a single-threaded renderer, and it is a reliable marker of whether an export library has been engineered for production use rather than for demos.
Stability is an underrated second benefit. When an exception happens inside a worker, only that job fails; the page keeps running, and the failure can be retried. Combined with timeouts and retry logic at the message layer, export jobs become recoverable. A crash during main-thread rendering, by contrast, can blank the page or take down the whole application, forcing the user to reload and start over. The difference in risk profile is fundamental, and it grows more visible the longer your application runs in production.
There is also a user-perception angle: a page that stays interactive during export changes how the feature is experienced. Users can verify content, fix mistakes, and retry without losing their place, and they stop associating export with freezing. Over time, that trust is what makes export a feature users reach for without hesitation.
Wrapping the dompdf.js call inside a module worker decouples export logic from page logic completely: business code posts a job and receives a result, and the main thread never performs rendering work itself. The worker script is loaded with type: 'module', so the import statement is handled correctly by Vite, Webpack, and other build tools, which bundle the worker as a standalone file in production and manage its resource paths for you.
Keep the message protocol in the shape { jobId, ...payload }: jobId correlates requests with responses and supports concurrent jobs and out-of-order replies, while the payload carries the HTML snapshot and format parameters, kept minimal to reduce cloning cost. When status: 'done' arrives, trigger the download or update the UI; when status: 'error' arrives, surface the message and log it. The protocol is simple but covers both success and failure paths completely, and adding new fields later does not break existing jobs.
Worker count and job granularity need a deliberate trade-off. A single worker processing jobs serially is the simplest option and uses the least memory; it suits the majority of applications. If you genuinely need parallel exports, create a small number of workers based on CPU count, but remember that every worker initializes its own WASM instance, so memory grows roughly linearly with worker count. Measure before scaling, start with two workers, and only add more if measurements justify it.
// pdf-worker.js — a module worker
import { DomPDF } from 'dompdf.js';
self.onmessage = async (event) => {
const { jobId, html, format } = event.data;
try {
const pdf = new DomPDF();
pdf.addPage(html, { format: format || 'A4' });
await pdf.save(`report-${jobId}.pdf`);
self.postMessage({ jobId, status: 'done' });
} catch (error) {
self.postMessage({ jobId, status: 'error', error: String(error) });
}
};
// main.js — dispatch a job from the main thread
const worker = new Worker(new URL('./pdf-worker.js', import.meta.url), {
type: 'module',
});
worker.postMessage({ jobId: 1, html: reportHtml, format: 'A4' });
worker.onmessage = (event) => {
if (event.data.status === 'done') console.log('PDF generated');
if (event.data.status === 'error') console.error(event.data.error);
};
The snapshot is the single largest piece of data crossing the thread boundary, so its size directly determines transfer cost. Keep the HTML you send to the worker as lean as possible: include only the nodes needed for the export, strip decorative wrappers, and prefer URL references over inline data URLs for large images, compressing or cropping them during snapshot preparation when necessary. Smaller messages mean faster cloning, faster parsing, and less memory pressure on both sides.
postMessage uses structured cloning, whose cost scales with payload size. For one-off exports, sending the HTML string plus parameters in a single message is the most straightforward design. When the same template is reused across many jobs — invoices generated from a list, reports rendered per tenant — split the template into a static part and a data part, cache the static part inside the worker, and transmit only the changing data. Repeated exports then pay a fraction of the original transfer cost.
Message timing deserves design too. Model the lifecycle explicitly: job started, progress update, job completed, job failed — each maps to a distinct message type. The main thread keeps a job table keyed by jobId and clears entries when the terminal message arrives, preventing memory buildup. Attach a timestamp to every message so logs can reconstruct the full timing of an export; when performance regresses, the breakdown immediately shows which stage became the bottleneck instead of leaving you guessing.
The worker wrapper also gives you a single choke point for operational concerns. Log every job with its id, payload size, and duration; track success and failure rates; and forward errors to your monitoring platform from one place. The wrapper is the natural home for such instrumentation, because every export in the application passes through it.
Long exports need progress feedback; without it, users stare at a static screen and assume the page hung. Add a progress message type to the protocol, have the worker report a value at key rendering milestones, and let the main thread render a progress bar or percentage. Throttle progress messages — reporting every five percent or roughly once per second is plenty — because high-frequency postMessage calls can measurably slow down the very render you are trying to report on.
Error handling needs three layers. The first is try/catch inside the job, which captures exceptions and returns an error message. The second is the main thread's error event listener, which catches worker script load failures — for example, a wrong path or a blocked module fetch. The third is a timeout: if a job produces no message within a deadline, treat it as failed and surface a clear notice. With all three layers in place, no failure mode is silent; production systems should additionally forward errors to a monitoring platform so issues are seen before users report them.
WASM initialization failure is the most common worker-specific pitfall. Browser caching policies, CORS restrictions, or build configuration errors can all prevent the wasm file from loading inside the worker context. Catch initialization errors in the worker and post a descriptive message, and prepare a fallback: when the worker cannot be used, call dompdf.js directly on the main thread. Export functionality continues to work with a slightly degraded experience, and availability is preserved — a fallback design that proves its worth in production incidents.
One more design point: keep the protocol backward compatible by making new fields optional and versioning the message shape when breaking changes are unavoidable. A worker that understands only version one should still process jobs from a client that sends version two fields, as long as the core contract is unchanged. This discipline keeps worker and client upgrades independent.
Batch export is a staple of back-office systems: a user selects dozens of reports and generates PDFs for all of them in one action. Implement it as a worker queue: the main thread maintains a pending list, dispatches jobs to the worker one at a time, advances the queue and updates the UI as each job completes, and records failures separately, presenting a summary when the whole batch finishes. One action, one wait, one clear result — a much better experience than per-document export buttons.
Memory management deserves special attention in batch scenarios. Every snapshot is a full DOM copy, and queued snapshots can consume memory quickly. Trim snapshot content before dispatch, release references to completed jobs promptly, and prefer URLs over data URLs for images. Watch the memory curve while a batch runs; if it climbs toward the browser's limits, reduce concurrency. Rendering speed means nothing if memory pressure crashes the tab, so keeping the curve flat is the real success criterion.
Persistence is the third pillar of a production queue. If the page refreshes mid-batch, unfinished jobs should be recoverable — at minimum, record task state in local storage or on the server. Users routinely walk away from operations that take minutes, and a batch export is exactly that. Recoverable tasks plus a completion notification are what turn a batch feature from a toy into something users trust with real work.
Progress reporting also improves failure diagnosis. If a job stalls, the last reported progress value tells you which stage it died in, and the timing between progress messages reveals slowdowns. Combine progress telemetry with the error payloads and you have a complete picture of every export's lifecycle, which is precisely what support teams need when a customer reports a slow or failed export.
On the deployment side, configure long-lived caching for worker files and wasm assets, and confirm CORS headers when the worker or wasm is served from a different origin. Give worker files content hashes at build time so cache invalidation is automatic and stale versions never linger in production. Add the export pipeline to your monitoring and alerting: track success rate, average duration, and the distribution of failure reasons so performance regressions surface before users complain.
When something does go wrong, work through the failure modes in order. A worker that never responds usually means the module failed to load — check the network panel for the worker script and the wasm file. A job that fails intermittently often points to cache staleness or a race between resource loading and rendering. Errors that reproduce only in production usually trace back to path assumptions that differ between the dev server and the deployed origin; compare the two request URLs side by side and the discrepancy becomes obvious.
The practices that matter most, in summary: keep the message protocol explicit and versioned; throttle progress updates; implement the three-layer error model; trim snapshots before transfer; run batch exports through a serial queue first and scale only with measurements; and ship a main-thread fallback. Follow these, and PDF export becomes a background service your application provides quietly and reliably — the UI never blocks, failures are visible and recoverable, and the feature earns user trust over years of daily use.
And when a regression does slip through, the fallback path keeps the feature alive: with the main-thread renderer as a safety net, an export that fails in the worker can still complete, just more slowly. Teams that ship this safety net rarely need it, but they are consistently glad it exists when a browser update or a misconfigured CDN breaks the worker path.
下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:
这是由 dompdf.js 渲染的示例 PDF 内容。