← dompdf.js Studio

Incremental Rendering with dompdf.js: A Performance Guide

When you have to export a very long document, the worst instinct is to stuff everything into one render call. A giant DOM snapshot consumes memory instantly, rendering blocks for a long time with no feedback, and when something goes wrong, the failure is hard to localize. dompdf.js offers a natural way out: its addPage interface is designed for incremental construction. Each call adds a page or a content block, and the document takes shape across many calls, which turns a monolithic export into a series of small, observable, recoverable steps. Combined with chunked snapshots, progress feedback, and a task queue, a thousand-page export can be decomposed into units that the user can watch, that can be retried independently, and that never freeze the page. This guide explains the core idea of incremental rendering and where it applies, then walks through a complete code example of building a document section by section with addPage. From there it covers progress callbacks and perceived performance, memory and resource optimization, batch export queues, cancellation and recovery, and finally the common problems and best practices that determine whether long-document export feels like a reliable engineering capability or a gamble that occasionally pays off. Throughout, the emphasis is on engineering practice rather than theory: the code examples are runnable, the advice is grounded in the library's actual worker-based pipeline, and every section ends with a decision you can apply to your own templates. Whether you export fifty-page manuals or thousand-page regulatory filings, the same principles scale.

What Incremental Rendering Means: Divide and Conquer

Incremental rendering is the practice of splitting one large task into many small ones. For PDF export, the small units are pages or content blocks added one at a time: each block is measured and typeset independently, and the result of one block does not affect the next. The boundaries between blocks are explicit, so a failure can be retried for just the failing block instead of restarting the entire document, and debugging is scoped to a specific piece of content rather than an undifferentiated blob of HTML.

Chunked construction has a hidden advantage beyond retryability: because each block's DOM is small, snapshotting and style computation are lighter, and the memory peak drops significantly. For structurally complex content such as charts and long tables, rendering each as its own block also gives you precise control over pagination — content can be kept together on one page instead of being sliced mid-row by an automatic page break. Layout quality improves precisely because you are no longer leaving pagination entirely to chance.

The approach fits best when content is naturally organized into sections: contract clauses, report chapters, user manuals, per-store statements. For content that is one continuous stretch of text, slice it by heading or by a fixed length, then add each slice with addPage; the same principle holds. The real design question is choosing the granularity — too small and message and bookkeeping overhead dominates; too large and you are back to the original monolith. A good rule of thumb is one block per logical unit, and splitting further only when a single block proves too heavy.

Incremental rendering also changes the failure story for the better. In a monolithic export, a single corrupt image or malformed table halfway through the document fails the whole job after minutes of work. With per-block construction, the failing block can be isolated, fixed, and retried while the completed blocks are reused. Over time, teams accumulate a library of known-problem patterns — huge tables, pathological CSS, enormous SVGs — and the block boundaries give them a natural place to apply targeted mitigations.

Code Example: Building a Document with addPage

The example shows the minimal skeleton of incremental construction: a cover page and each chapter get their own addPage call, and the document is saved once after all blocks are in place. addPage accepts either an HTML string or a DOM element, and repeated calls build a multi-page document in call order. This skeleton is the foundation that every long-document export in your application can grow from, regardless of content type.

In real projects the per-chapter loop is typically driven by data: an array fetched from an API or assembled locally, with a template function producing the HTML fragment for each entry. Keeping export logic decoupled from the data source means new chapters require no changes to export code — only data and templates evolve, and the rendering engine stays untouched. This separation also makes the export pipeline unit-testable with synthetic chapter arrays.

Consistency across blocks deserves deliberate attention. Every block's HTML should carry the shared styles — fonts, margins, line height, and page margins — or the shared styles should be factored into a constant that is interpolated into each block. Otherwise pages drift visually: one page in one typeface, the next in another, spacing inconsistent between chapters. Centralizing the style constant means one edit fixes the whole document, and every page looks like it belongs to the same publication.

import { DomPDF } from 'dompdf.js';

const chapters = [
  { title: 'Chapter 1 — Overview', body: '<p>Project background, goals, and scope.</p>' },
  { title: 'Chapter 2 — Data', body: '<table><tr><td>Metric</td><td>Value</td></tr></table>' },
  { title: 'Chapter 3 — Conclusions', body: '<p>Findings and recommendations.</p>' },
];

const pdf = new DomPDF();

// Cover page: its own addPage call
pdf.addPage('<h1 style="text-align:center">Annual Business Review</h1>', { format: 'A4' });

// Chapters added incrementally, one page each
for (const chapter of chapters) {
  const html = `<h2>${chapter.title}</h2>${chapter.body}`;
  pdf.addPage(html, { format: 'A4' });
}

// All blocks ready — save once
pdf.save('annual-review.pdf');

Progress Feedback: Making Long Exports Feel Fast

A long export without progress feedback is an exercise in user anxiety. dompdf.js renders page by page inside a worker, and with per-block progress accounting you can report precise values such as page 12 of 58: each completed block advances a counter, and when all blocks finish, the final save runs and the bar moves smoothly from zero to one hundred percent. The difference between a static spinner and a live counter is not cosmetic; it changes whether users trust the operation or assume it hung.

Implementation is a single counter. The total number of blocks is known in advance, each completion increments the count, and progress equals completed divided by total. Keep block sizes roughly uniform so the bar advances evenly; if one block dominates, split it further. Throttle UI updates with requestAnimationFrame so progress rendering never competes with the export itself for main-thread time, and update the displayed percentage at most a few times per second.

Progress data has a second life in observability. Log the per-block timings for every export, and you can compute where time actually goes: snapshot preparation, font loading, layout, or the final save. When a regression appears — exports suddenly slower after a template change — the timing breakdown points at the responsible block and stage directly. Teams that instrument exports this way find that performance discussions stop being speculation and become data review.

Perceived performance also benefits from ordering work smartly. Because blocks render independently, you can add the first page first and save the heaviest blocks for last, so the user sees output start quickly. Alternatively, render the cover and summary early and stream pages in the background. Incremental construction gives you the freedom to decide what the user perceives first, which is a lever that monolithic rendering simply does not offer.

Memory and Resource Optimization

Memory optimization for incremental generation centers on controlling snapshot size. Keep each block's HTML lean, split long tables into several smaller blocks, and compress images before embedding rather than shipping them at original dimensions. Release DOM references and temporary objects as soon as a block completes so the garbage collector has a chance to work, and the memory curve stays flat instead of climbing with every page.

Image-heavy documents deserve a preprocessing pass before any rendering begins: scale images that exceed the page width proportionally, convert bitmaps to compact encodings, and drop purely decorative images. Images are the dominant contributor to both PDF size and memory, so preprocessing once benefits rendering, storage, and transfer simultaneously. This is the highest-return optimization available for most real-world templates, and it costs an afternoon to implement.

Fonts belong in the memory budget too. A custom font is several megabytes to tens of megabytes per file, and in an incremental build the whole document shares one font resource — do not reload it per block. Load font files once, reuse them globally, and never inline duplicate font data into snapshots. Otherwise the overhead grows linearly with the number of blocks, and the font wait time becomes a tax on every additional section you add to the document.

A practical discipline that pays off: set a per-block size budget during development. If a block's HTML exceeds the budget, the template needs work before it reaches production. Budgets turn memory management from an abstract concern into a concrete, enforceable rule, and they catch problems at authoring time rather than during a late-night incident with a customer's thousand-page contract.

Progress data also feeds better product decisions: if the average export completes in under two seconds, the progress bar can be replaced with a simple spinner, whereas longer exports justify richer feedback. Instrumenting progress first lets you decide such questions with data instead of guessing.

Batch Export and Task Queues

Batch export — generating many PDFs in one operation — is a different layer of engineering than incremental construction within a single document. The queue layer is responsible for scheduling: enqueueing jobs, executing them serially or concurrently, retrying failures, and summarizing results. The execution layer uses incremental construction to keep each job fast and memory-light. Keeping the two layers separate means each can evolve independently, and when something goes wrong, the fault is immediately classifiable as a scheduling problem or a rendering problem.

On concurrency, start serial and add parallelism only with evidence. Process one job at a time, observe per-job duration and memory, and only when headroom is confirmed introduce light concurrency — two simultaneous jobs is a reasonable first step. Concurrency is not free: each worker and each WASM instance consumes memory, and beyond a certain point additional concurrency makes every job slower rather than faster. Load-test to find the sweet spot for your hardware and document it.

Persistence completes the queue design. If the page refreshes mid-batch, unfinished jobs should recover — at minimum, record task state in local storage or on the server. Users walk away from operations that take minutes; that is normal behavior, not a defect. Recoverable tasks, per-job status, and a completion notification are what make a batch feature trustworthy enough for users to rely on for real work, and they are cheap to add at design time and expensive to retrofit later.

Result handling matters as much as scheduling. A batch that generates fifty PDFs should end with a clear summary: forty-seven succeeded, three failed, and here is why each failure happened, with a retry button scoped to the failures. Collecting per-job errors into one report keeps the user in control and prevents the silent partial-failure pattern where users discover missing documents days later.

Cancellation, Retry, and Failure Recovery

Cancellation is the feature that separates good export UX from merely functional export UX. During a long export, users may notice an error in the content, or simply change their minds; a cancel button should stop the job, release its resources, and return the UI to an editable state. Implement cancellation with an explicit task state machine — pending, running, completed, cancelled — and check the state between blocks so cancellation takes effect promptly rather than after the whole document finishes rendering.

Retry deserves the same first-class treatment. Because blocks are independent, a failed job can be retried by re-running only the failing blocks instead of the entire document. Keep the failure metadata — which block, what error, at what stage — attached to the job, and present retry as a one-click action. Automated retry with backoff is appropriate for transient failures such as image fetch timeouts, while permanent failures should surface for human review rather than looping forever.

Failure recovery also means handling the environment, not just the code. A user's machine may run out of memory, the network may drop mid-export, or the browser tab may be backgrounded and throttled. Design the export module to detect these conditions: monitor memory via performance APIs, watch for aborted fetches, and listen for visibility changes. Each condition maps to a graceful response — reduce concurrency, resume the queue, or pause and notify. The pipeline that handles environment failures gracefully is the one that survives production.

Finally, record everything for post-mortems. Per-job logs with timestamps, block durations, and error codes cost little to produce and are invaluable when a customer reports a failure you cannot reproduce. A structured log entry per job — jobId, template version, block count, duration, outcome — turns support tickets into data points and gives you the material to fix root causes instead of symptoms.

Common Problems and Best Practices

Q: Pages come out in the wrong order after incremental adds? A: addPage appends pages strictly in call order. Check whether asynchronous callbacks are invoking addPage out of sequence; build sections with a synchronous loop or serial awaits, and if concurrency is unavoidable, add an explicit sequence number to every block and verify order in logs.

Q: The document is still slow even after chunking? A: Locate the bottleneck before optimizing: snapshot preparation, font loading, or rendering. Profile each stage with the browser's performance tools and instrument per-block timings. Slow snapshots point at template bloat; slow fonts point at subsetting; slow rendering points at oversized blocks. Targeted fixes beat blanket optimization every time.

Q: Memory climbs during batch exports? A: Check that completed jobs release their references, that snapshots are trimmed before dispatch, and that images travel as URLs rather than data URLs. If the curve still climbs, reduce concurrency. Memory is the constraint that actually bounds batch throughput; treat the flat curve as the success criterion, not raw speed.

Best-practice summary: organize content into blocks and add them with addPage; centralize shared styles in one constant; track progress per block with throttled updates; preprocess images and share fonts globally; run batches through a serial queue and scale with measurements; implement cancellation and retry with a visible task state machine. Follow these, and long-document export moves from luck to predictability — thousand-page documents deliver reliably, failures are isolated and recoverable, and the feature earns the trust that production users quietly depend on.

A final habit worth adopting: keep a performance regression checklist with the last known-good timings for each template family. When a report suddenly takes twice as long, the checklist tells you which template changed and which block is responsible, cutting investigation time from hours to minutes. Long-document export is a system, and systems need baselines.

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

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

Hello from dompdf.js!

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