Memory problems in frontend PDF generation tend to erupt exactly when documents get large: the page stutters, the browser tab crashes, generation fails midway, and the root cause is almost always an uncontrolled memory peak. The DOM snapshot, the decoded image bitmaps, the WASM linear memory, and the final PDF byte buffer each consume memory, and stacked together the peak becomes formidable. dompdf.js manages WASM memory deterministically, but the calling code still decides the actual peak: how images are handled, how instances are reused, and how large tasks are queued all move the memory numbers in real ways. This guide breaks down where the memory actually goes, explains the division of labor between image bitmaps and WASM linear memory, shows code-level techniques for keeping resource lifecycles predictable, covers throttling and queueing for batch exports, and closes with monitoring practices and an ordered tuning checklist. The goal is a working balance between memory and speed, so that PDF generation stays stable and responsive even for large documents and high-frequency exports, and so memory stops being the thing that brings down an otherwise healthy feature. The checklist is ordered by return on effort so a quick pass produces measurable improvement, and the monitoring section turns memory from a black box into a tracked, reviewable metric. Whether you export once a day or a thousand times an hour, the same principles apply, and the guide gives you the vocabulary to discuss memory with confidence. It also explains when memory is genuinely a problem worth engineering for and when simple hygiene is enough, so you spend effort where it matters.
A single PDF generation consumes memory in four layers: the DOM snapshot and style data, the decoded image bitmaps, the layout and rendering data inside WASM linear memory, and the final PDF byte buffer. Bitmaps are usually the heavyweight: a single 4000×3000 photograph occupies roughly 48MB once decoded, and dozens of images can consume hundreds of megabytes on their own. For image-heavy documents, bitmaps are the first thing to attack.
How the four layers stack determines the peak: the high-water marks of snapshotting, decoding, rendering, and encoding do not overlap completely, but when large images decode in a concentrated burst, the peak jumps sharply. Understanding this makes the control strategy obvious — the goal is not to reduce total memory but to spread the load: decode images in batches, render content in chunks, and the peak drops while the total stays the same. Peak, not total, is what causes crashes.
WASM linear memory grows in pages and does not automatically shrink after a peak: one heavy render can leave memory elevated for the life of the page. For a one-off export that is harmless; for high-frequency export flows, memory reclamation behavior directly affects long-term page stability. That asymmetry — cheap single exports, expensive habits — is precisely why memory management deserves its own guide rather than a footnote in a performance article.
There is also the invisible cost of string intermediates: template strings, data URLs, and base64 payloads all live as JavaScript strings, which are immutable and can double memory usage during concatenation. Building large templates incrementally with arrays and join, rather than repeated string concatenation, keeps this hidden layer small.
A final point on the four layers: they are not all under your control, but the two largest usually are. Bitmaps and DOM snapshots respond directly to how you prepare content, which is why the later sections of this guide concentrate there rather than on the engine's internal allocations.
Image bitmaps are the first battlefield of memory management. Before decoding a large image, ask three questions: what display size is needed, how many times will it appear, and does it genuinely have to be the original. Display size sets the lower bound of the bitmap, so downsampling to within twice the display size cuts memory several-fold; an image used in multiple places should be decoded once and reused, never duplicated into several bitmaps.
The canvas element is the standard downsampling tool: drawImage renders at the target size, and toDataURL exports the result. Do the downsampling immediately after decoding so the large bitmap never lingers in memory, and release Blobs and URLs as soon as they are consumed. Bitmap lifecycle management is the most direct memory win available, with modest code and measurable effect, and it belongs in every image pipeline feeding the PDF generator.
Format choice also moves the numbers: a JPEG decodes to the same RGBA bitmap as a PNG, but the source file is smaller and decodes faster. For size-sensitive documents, JPEG sources with moderate compression control both the delivered file size and the temporary memory during decode. Planning image strategy together with format selection closes the loop between file size and memory, two optimizations that reinforce each other.
Watch out for image decodes triggered implicitly by layout: when the same URL appears in several places, browsers share the decoded bitmap, but if the template forces different intrinsic sizes through CSS, some engines decode additional variants. Normalizing sizes in the template keeps the shared-cache benefit intact.
The three questions also apply to non-photographic assets: a large illustration or a detailed screenshot benefits from the same downsampling discipline. The principle is universal — decode only what the document needs — and applying it uniformly keeps every image type from becoming a memory outlier in the export flow.
Memory control at the code level is about making resource lifecycles predictable: release when done, reuse instances, and null out large references. The example below shows a memory-friendly flow: images are downsampled before entering the template, Blob URLs are revoked after use, image references are released after export, and the DomPDF instance is left for the scope to clean up. None of these steps is dramatic; together they keep the water level low across repeated exports.
The example revokes the object URL created for each image as soon as decoding and drawing are done, because the URL is only needed to load the image once. Large image references are dropped after the canvas export, and nothing is retained in a closure or a global. These details look trivial in isolation, but in high-frequency export flows they are the difference between a page that stays smooth for hours and one that degrades into jank and crashes.
Avoid closures and globals holding large objects: template strings and image data should not outlive the generation they serve. Memory reclamation is the browser GC's and the WASM allocator's job, but both can only free what the code no longer references. Releasing references is the one part only the developer can do, and doing it consistently solves a large share of memory problems before they start.
A practical rhythm: after each export completes, run a tiny audit in the console — how many large strings and bitmaps are still reachable, and what does the WASM memory buffer length look like. A two-minute check after a heavy generation reveals leaks long before they accumulate into user-visible degradation.
The example also shows a useful pattern for shared utilities: the prepareImages function encapsulates the whole lifecycle — fetch, decode, downsample, encode, release — so callers cannot accidentally leak the intermediate objects. Encapsulating the lifecycle in one function is the most reliable way to keep memory hygiene consistent across a team.
import { DomPDF } from 'dompdf.js';
async function prepareImages(urls) {
return Promise.all(urls.map(async url => {
const blob = await (await fetch(url)).blob();
const img = new Image();
img.src = URL.createObjectURL(blob);
await img.decode();
const canvas = document.createElement('canvas');
const max = 1200;
const scale = Math.min(1, max / Math.max(img.naturalWidth, img.naturalHeight));
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
URL.revokeObjectURL(img.src);
return canvas.toDataURL('image/jpeg', 0.85);
}));
}
const images = await prepareImages(['images/a.jpg', 'images/b.jpg']);
const html = `
<style>body { font-family: 'Source Han Sans SC', sans-serif; }</style>
<h2>Memory Optimization Example</h2>
<img src="${images[0]}" style='width:100%'>`;
const pdf = new DomPDF({ format: 'A4', margin: '20mm' });
await pdf.addPage(html, { format: 'A4' });
pdf.save('memory-demo.pdf');
WASM linear memory and the JavaScript heap are two separate memory worlds: layout, rendering, and PDF-encoding data live on the WASM side, while DOM, strings, and image Blobs live on the JavaScript side. Data crossing between the two must be copied, and the copy volume sets the transient memory cost. This is the second key to understanding memory peaks, and it explains why some optimizations work even though they seem unrelated to memory at first.
The ways to reduce crossing volume overlap almost exactly with the performance advice: slimmer DOM means a smaller snapshot to copy, downsampled images mean fewer pixels to move, and avoiding duplicate data means each byte is copied once rather than several times. Every copy adds one more instance of the data to the peak, so reducing copy count reduces the peak proportionally. The two optimization tracks — speed and memory — converge on the same practices.
WASM memory not shrinking after growth is by design: the engine prefers keeping allocated memory over paying repeated expansion costs. For long-lived pages, check the WebAssembly.Memory buffer length and the page's overall footprint periodically, and correlate them with the browser task manager. Steady, bounded memory is normal; a monotonic climb across many exports is a signal worth investigating before it becomes a crash.
One subtlety: because linear memory is a contiguous buffer, fragmentation behaves differently than in the JS heap. Repeated small allocations and frees can leave the buffer larger than the live data justifies. If you control the document structure, batching allocations — for example, building all pages before rendering — produces denser, more efficient use of the linear memory region.
One more diagnostic: if memory climbs steadily across exports of the same document, suspect a leak in your own code rather than the engine — a retained reference to the template, the Blob, or the image URLs. A heap snapshot comparison across two exports isolates the retained objects quickly, and the fix is usually one released reference.
Batch export is an amplifier of memory pressure: dozens of export jobs running at once multiply the peak and can crash the browser outright. Queueing is the standard fix: jobs run strictly in sequence, the next starting only after the previous finishes, which caps instantaneous memory at the scale of a single job. Stability and predictability improve dramatically, and users who wait in a queue are far happier than users whose exports all fail together.
Throttling complements the queue: a small gap between jobs, such as 50 to 100 milliseconds, gives the GC a chance to reclaim the previous job's temporary objects. In a long queue the total time increase is modest while the memory water level drops noticeably; for very large documents, split the job into multiple pages rendered in sequence to spread the peak further. The two techniques together keep batch memory flat regardless of queue length.
A robust queue enforces error isolation: a failed job must not block its successors — record the failure reason and continue — and shared resources such as a preloaded image cache must be managed separately so they are not released mid-queue. A queue that degrades gracefully is the baseline requirement for shipping a batch export feature at all; it is also the difference between an incident and a support ticket.
Consider adding cancellation to the queue: users will inevitably start a large batch and want to stop it. Cancellation should stop scheduling new jobs, drain the in-flight job, and release its resources cleanly, so the memory it held is returned promptly rather than lingering until the queue finishes on its own.
The queue also gives you a natural place to enforce resource limits: reject jobs when the queue is too long, or degrade quality for queued exports when memory is tight. Policy belongs in the queue, not scattered across callers, and a single choke point is far easier to tune than a dozen independent call sites.
Set up monitoring before tuning anything: the browser task manager and performance.memory show page memory trends, and the developer tools memory panel captures heap snapshots for before-and-after comparison. Logging the memory delta around each generation turns memory from a black box into a tracked metric, and when something anomalous appears, the logs say which document shape and which stage caused it.
The tuning checklist, ordered by return: image downsampling and format unification give the largest gain at the lowest risk; DOM reduction and snapshot slimming cost nothing; instance reuse and reference release are the code-level fundamentals; and queueing with batched rendering solves the batch-scenario peak. After each change, watch the memory curve to confirm the gain is real before moving to the next item — memory optimization without measurement is guesswork wearing a lab coat.
Finally, make memory part of the release standard: before every feature ships, run a memory benchmark on a representative scenario and alert when the peak exceeds a threshold. Memory problems are cumulative — the earlier they are caught, the cheaper they are — and baking monitoring and baselines into the process keeps PDF generation stable for years rather than firefighting every time a document grows.
Publish the memory baseline next to the performance baseline in your release notes or CI output: a table of document size, memory peak, and generation time per version. The two numbers move together, and tracking them side by side makes the trade-offs visible — an optimization that shaves time at the cost of memory shows up immediately, and you can decide with data instead of discovering the cost later in production.
The publishing step closes the loop: when the memory baseline is visible to the whole team, memory stops being one person's concern and becomes a shared, reviewable constraint. That is the difference between a feature that quietly degrades over time and one that is actively maintained, and it costs only the discipline of running the measurement.
下面的按钮用 dompdf.js 在浏览器端实时生成 PDF,无需后端:
这是由 dompdf.js 渲染的示例 PDF 内容。