Batch export is a hard requirement in almost every business system. Accounting needs a month of invoices in one file, HR needs payslips for every employee, and finance needs quarterly report compilations. The conventional answer is to push these jobs to a backend queue, where waiting, timeouts, and overnight cron jobs become part of the routine, and peak periods mean all-night batch runs. dompdf.js inverts that model. Its Rust+WASM core renders directly in the browser — roughly a thousand pages in about two seconds in our benchmarks — so batch jobs run on the client, instantly, with no queue at all. You can loop addPage to assemble one multi-page document, or loop save to download many individual files. This guide covers both patterns with complete code, performance and memory optimization techniques, progress-reporting UX, and the pitfalls that trip people up at scale, so you can ship batch export that is both fast and stable. Whichever shape your batch job takes, the code patterns in this guide are designed to scale from a handful of records to thousands without a rewrite. The performance techniques apply to both patterns, the UX section covers what users actually experience during a long export, and the FAQ answers the questions that most often surface once real users start hammering the export button.
Batch export comes in two distinct shapes. The first merges many records into a single multi-page PDF: an invoice book, a report compilation, a full ledger. The second splits records into separate files: one payslip per employee, one delivery note per order, one certificate per student. The two shapes serve different business needs and have different code structures.
The implementation paths differ. Merging means looping addPage on a single DomPDF instance and calling save once at the end. Splitting means looping the entire create-and-save cycle, with the browser firing a download for each save call. Both are simple, but they behave differently under load and in the browser's download policy, so each needs its own design.
Which shape you choose depends on the use case. Choose the merged document when records need archival in one place; choose separate files when each recipient needs their own document. Many products offer both options and let the user decide at export time — the code cost of supporting both is low, and the product feels more complete for it.
Merged documents are the better fit when the records form a single narrative — a monthly statement, a project report, a compiled ledger — because the reader experiences them as one document with continuous page numbering. Separate files fit when each record has its own audience and must be routed independently, as with payslips, certificates, or per-order delivery notes. Choosing the shape by audience, rather than by implementation convenience, produces the better product.
There is also a middle option worth knowing: a merged document with a cover page or table of contents. For large compilations, generating a cover page first, then looping the records, gives the merged file a professional structure while keeping the code nearly identical. The pattern also makes it trivial to add per-record separators or section headers when the data is heterogeneous.
A practical detail when records are fetched from an API: decide whether the export uses the data already loaded in the UI or refetches a fresh snapshot. Refetching guarantees consistency but adds latency; using cached UI data is faster but can export stale values. For financial documents, most teams prefer a fresh snapshot plus a timestamp on the cover page, so the exported file documents exactly what period it covers.
Whichever shape you choose, log the export parameters at the start of the job — record count, output type, user, and timestamp. When a user later reports a wrong or missing file, the log turns a vague complaint into a reproducible case, and it costs a single console statement per job.
import { DomPDF } from 'dompdf.js';
const invoices = [
{ no: 'INV-20260801', amount: '¥ 1,200.00' },
{ no: 'INV-20260802', amount: '¥ 3,450.00' },
// ... hundreds more invoice records, fetched page by page
];
const pdf = new DomPDF();
for (const inv of invoices) {
const pageHTML = `
<h2 style="text-align:center">Invoice ${inv.no}</h2>
<table style="width:100%;border-collapse:collapse">
<tr><th style="border:1px solid #000;padding:8px">Invoice No.</th>
<th style="border:1px solid #000;padding:8px">Amount</th></tr>
<tr><td style="border:1px solid #000;padding:8px">${inv.no}</td>
<td style="border:1px solid #000;padding:8px">${inv.amount}</td></tr>
</table>
<div style="page-break-after: always"></div>`;
pdf.addPage(pageHTML, { format: 'A4', margin: '15mm' });
}
// Save once after all pages are rendered
pdf.save('invoices-2026-08.pdf');
import { DomPDF } from 'dompdf.js';
async function exportAll(employees) {
for (let i = 0; i < employees.length; i++) {
const emp = employees[i];
const slipHTML = `
<h1>${emp.name} Payslip</h1>
<p>Base salary: ${emp.base} Bonus: ${emp.bonus}</p>`;
const pdf = new DomPDF();
pdf.addPage(slipHTML, { format: 'A4' });
pdf.save(`${emp.name}-salary-2026-08.pdf`);
// Update the progress bar so users see live progress
updateProgress(i + 1, employees.length);
// Yield to the main thread so the UI stays responsive
await new Promise(r => setTimeout(r, 50));
}
}
exportAll(employeeList);
The WASM core renders extremely fast — roughly a thousand pages in about two seconds — so rendering is rarely the bottleneck. The real cost is usually data preparation and HTML string assembly. Normalize your data into arrays first, then generate page HTML from a single template, formatting amounts and dates outside the loop so the template only interpolates ready-made values.
When exporting many independent files, each new DomPDF() is an independent instance that can be released after save. Adding an await setTimeout between iterations yields the main thread, so the page keeps scrolling smoothly and the progress bar keeps animating instead of freezing, which keeps the UI responsive during long exports.
For very large exports, process in batches. Group records into chunks of 50-100, yield between chunks with requestAnimationFrame or setTimeout, and update the progress indicator after each chunk. Batched rendering feels dramatically better to users than one long blocking run, and it puts far less pressure on browser memory.
One memory detail worth remembering: null out HTML strings you no longer need inside the loop so large strings do not linger and push the memory peak higher. This matters most on very long lists and keeps the page stable even when the export runs for a while.
String assembly deserves more attention than it usually gets. Building HTML with repeated template-literal interpolation is fine up to a few hundred records, but concatenation in a hot loop can dominate the render time. Pre-compiling the page template once, outside the loop, and calling it as a function per record keeps the assembly cost flat as the dataset grows — a change that often halves total export time on large jobs.
Memory pressure also comes from the data itself, not just the strings. If each record carries a large nested object — audit history, image thumbnails, raw API responses — map the records down to only the fields the template needs before the loop starts. Lean records mean lean strings and a lower peak memory, and the export code becomes easier to read because the data shape is explicit.
Reuse the same DomPDF instance where possible. Rendering multiple pages on one instance amortizes initialization and font-loading costs across all pages, which is why the merged-document loop is faster per page than the per-file loop. If you need separate files, consider whether a merged archive plus per-file extraction later is an acceptable trade for the speed gain.
One more tip for very large merged documents: render a few pages first and inspect them before launching the full job. A typo in the template or a missing field repeats hundreds of times in a batch export, so a five-page preview catches the class of bug before it wastes minutes of render time — cheap insurance for a routine workflow.
The worst thing a batch export can do is make users think the page crashed. A client-side renderer has an advantage here: you can update a progress bar after every record, giving users a real-time view of how far the job has come and how long remains, which removes most of the waiting anxiety.
Browsers restrict automatic downloads — a session that triggers many downloads in a row will start showing permission prompts or silently blocking them. When exporting many separate files, tell users in advance, or offer a bundled single-file option as an alternative, and never let downloads fail silently.
For extreme volumes, such as tens of thousands of records, consider loading data page by page and passing only the selected record IDs into the export routine. Fetching the entire dataset at once risks memory spikes that slow the browser to a crawl; letting users filter the range first also keeps the workload sane.
After the export finishes, show a result summary: how many files were generated, which records were skipped due to errors, and how long it took. A clear result list makes the feature feel trustworthy and gives support teams something concrete to investigate when something goes wrong.
Progress reporting works best when it is honest. If you cannot know the total in advance, switch to an indeterminate indicator; if some records may fail, report successes and failures separately rather than one blended percentage. Users forgive slowness far more readily than they forgive a progress bar that lies or a job that dies without explanation.
Cancellation is a feature users quietly expect. Providing a Cancel button that sets a flag checked between records turns an unavoidable long job into a controllable one, and it prevents frustrated users from closing the tab and losing the whole export. A few lines of flag-checking code materially improve the perceived quality of the feature.
Also consider the download experience on mobile. Multiple sequential downloads behave differently on phones than on desktops, and file managers may group or rename them. If mobile users are a meaningful audience, default to the merged-file option or provide a bundle so recipients get one artifact instead of a stream of prompts.
Finally, remember that the export feature is part of the product, not an afterthought. A loading overlay with a progress bar, a completed summary, and a clear error state are the difference between a feature users trust and one they avoid. Budget the same care for the export UX as for any other core workflow.
Q: The generated PDF is missing pages? A: Check the loop for skipped addPage calls and confirm the data source was fully fetched. A simple guard — comparing your addPage call count against the data length — catches this instantly and prevents shipping an incomplete document.
Q: The browser blocks multiple downloads? A: That is a browser security policy. Merging everything into one multi-page PDF sidesteps it; otherwise guide users to enable automatic downloads for your site and explain why in the help documentation.
Q: Will a 1000+ page document exhaust memory? A: dompdf.js processes pages in a streaming fashion and has proven stable in practice. For truly extreme volumes, generate several smaller PDFs and let users save them individually rather than betting everything on a single run.
Q: How do I add headers, footers, and page numbers to every page? A: Configure them once with the @page paged-media properties, and every page added via addPage picks them up automatically — no per-page repetition needed, and maintenance stays trivial.
Q: Can the export run in the background? A: Synchronous browser exports occupy the main thread; for long tasks, split the data preparation into a Web Worker while the render itself stays in the foreground, combining responsiveness with stability.
Q: How do I keep the browser tab from going to sleep during a long export? A: Keep the page active by yielding to the main thread regularly and avoiding long synchronous stretches; browsers suspend tabs that are fully blocked. If a job must survive the user navigating away, move the heavy work to a Web Worker and reconstruct the PDF from the worker's output — but for typical in-page exports, the yield-between-chunks pattern is sufficient.
Q: What is the practical upper limit for a single merged PDF? A: In practice, documents in the hundreds of pages are routine, and thousand-page exports work with the streaming pipeline. The limits that appear first are usually browser memory and user patience, not the renderer — so cap the batch size in the UI, offer range selection, and let users split enormous jobs into several files with confidence.
Q: The export works in Chrome but fails elsewhere? A: Test the two hot spots first: download triggering and WASM loading. Both are well-supported in modern browsers, but older Safari versions and some WebViews have stricter policies; a fallback that triggers the download from a user gesture — a button click — sidesteps most of these restrictions.
Q: Do I need a backend at all for batch export? A: No — the entire pipeline runs client-side, which is the point. If the data itself lives on a server, fetch it first and then export; but the rendering, pagination, and file assembly all happen in the browser, so the server never becomes a bottleneck or a queue.
下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:
这是由 dompdf.js 渲染的示例 PDF 内容。