Full Performance Engineering Report
vue-pdf-export - Large Document Performance Engineering Report
Maintainer: Saurabh Choudhary
Package:vue-pdf-export
Milestone covered: Large-document performance, memory behavior, reliability, cancellation, progress, image reuse, cleanup, and regression hardening
Primary target: 50–100+ page browser-generated PDFs with images, headers, footers, watermarks, links, metadata, compression, and password protection
Executive summary
The performance work changed vue-pdf-export in a meaningful way. This was not only a benchmark exercise.
The project moved from a package that mainly depended on whole-document HTML-to-canvas behavior to one with explicit large-document engineering:
- page-by-page Canvas rendering for paginated documents
- real 50-page and 100-page Canvas correctness regression coverage
- static HTML footer reuse instead of rasterizing the same footer on every page
- page-level cancellation and recovery
currentPage/totalPagesprogress- cooperative browser yielding during long generation
- shorter lifetime for temporary DOM, canvas and encoded image data
- repeated-image caching in selectable-text mode
- detailed stress telemetry for actual page count, Blob size and canvas dimensions
- fresh-context and same-page memory isolation
- a reproducible security + raster memory-retention investigation
- a raw jsPDF reproduction proving the strongest encrypted-raster retention pattern is not explained only by the Vue component lifecycle
- an upstream jsPDF issue filed for the dependency-level behavior
- expanded Playwright regression coverage for large documents and cleanup
The biggest user-visible result is simple:
50-page and 100-page Canvas exports are now verified as real, non-empty PDFs on the incremental page-by-page path instead of relying on one giant whole-document canvas.
The biggest architecture result is:
For Canvas documents with
.html2pdf__page-breakmarkers, peak capture geometry is page-bounded instead of document-height-bounded.
The biggest diagnostic result is:
Large encrypted raster PDFs remain a higher-memory workload because jsPDF encryption retains substantially more raster-related memory in Chromium. The behavior was reproduced with raw jsPDF and is tracked upstream rather than mislabeled as a Vue memory leak.
1. Evidence reviewed for this report
This report is based on the complete performance material supplied for this milestone, including:
- the full
performance:everythingterminal run - the historical project/performance discussion and implementation record
- Phase 2.2 / 2.3 / 2.4 benchmark logs
- both
benchmark-resultsarchives - the latest and historical JSON benchmark output
- Canvas bottleneck isolation results
- general stress benchmark results
- Phase 2.2 fresh-context memory isolation
- Phase 2.3A security isolation
- Phase 2.3B large Canvas correctness
- Phase 2.4A security payload scaling
- Phase 2.4B Canvas-threshold diagnostics
- Phase 3.2 incremental Canvas + security re-test data
- Phase 3.2 same-page protected repeat data
- Phase 3.2.1 raw jsPDF reproduction data
- Phase 3.3 cancellation/recovery results
- Phase 3.4A selectable-image-cache regression
- Phase 3.4B Canvas cleanup/image-heavy regression
- Phase 3.5 long-Canvas routing regression and final 50/100-page fix
- final unit, build and Playwright release-gate results
Where a percentage is shown below, it is either:
- calculated from a directly comparable before/after benchmark pair, or
- explicitly labeled as a ratio/comparison rather than an overall package speedup.
No unsupported “overall package is X% faster” claim is made.
2. Original problem
Browser-side PDF generation becomes difficult when a document combines:
- many pages
- rasterized HTML
- large decoded images
- high
html2canvasscale - repeated headers/footers
- watermarks
- password encryption
- compression
- preview Blob URLs
- repeated generation in the same browser session
For a Canvas workflow, a rough pipeline is:
Vue DOM
→ browser layout
→ html2canvas
→ raster image
→ jsPDF
→ PDF Blob
A 100-page document is therefore fundamentally different from a two-page invoice.
The original engineering goals were:
- benchmark 10 / 25 / 50 / 100 pages
- measure generation time and output size
- measure peak and retained browser heap
- distinguish high peak memory from a real leak
- identify the actual bottleneck instead of guessing
- reduce Canvas peak memory
- investigate page-by-page rendering
- improve repeated-image handling
- add cancellation
- add current-page progress
- improve UI responsiveness during long work
- test cleanup after generation/cancellation
- stress-test complex feature combinations
- build repeatable regression coverage
Most of those goals are now implemented or covered by repeatable tests.
3. Phase 1 - benchmark and stress foundation
The first step was measurement, not optimization.
The project added a stress harness capable of exercising:
- Canvas mode
- selectable-text mode
- text-heavy content
- mixed content
- image-heavy content
- multiple page counts
- different Canvas quality levels
- security on/off
- headers
- footers
- watermarks
- metadata
- compression
- links
- logos
The harness records more than “did the Promise resolve?”
It can observe:
requested pages
actual PDF pages
Blob size
generation duration
DOM nodes
image count
manual break count
toDataURL calls
largest canvas width
largest canvas height
largest canvas pixel area
progress stage
current page
total pages
success/cancelled state
Why this mattered
A long PDF can “succeed” while still being wrong.
For example:
requested = 50 pages
actual PDF page objects = 50
Blob size = ~42 KB
That is technically a returned Blob, but it is clearly not a healthy 50-page raster PDF.
The telemetry therefore became a correctness tool, not only a benchmark tool.
4. Phase 2 - bottleneck isolation
Instead of optimizing everything at once, major features were isolated.
The Canvas isolation matrix included:
minimal text
images only
header without logo
header with logo
footer text
footer HTML
footer HTML + logo
single watermark
repeated watermark
metadata
security
compression
links
all features without images
all features mixed
This immediately found the first real performance bug.
5. Phase 2.1 - static HTML footer optimization
Problem discovered
The pre-optimization 25-page Canvas benchmark showed:
| Scenario | Before |
|---|---|
| Footer text | ~1.10 s |
| HTML footer, no logo | 7.82 s |
| HTML footer + logo | 11.81 s |
| HTML-footer PDF size | ~46.7 MB |
The reason was repeated rasterization.
The old behavior was effectively:
Page 1 footer → html2canvas → PNG
Page 2 footer → html2canvas → PNG
Page 3 footer → html2canvas → PNG
...
Page 25 footer → html2canvas → PNG
The footer body was static, yet the same HTML was being rasterized repeatedly.
For a 100-page document this could mean rasterizing the same footer approximately 100 times.
Change implemented
The static HTML footer is rendered once:
static footer HTML
→ html2canvas once
→ reusable PNG
Then that same raster is reused across pages.
Dynamic page numbering remains vector/text work:
Page {n} of {total}
→ jsPDF text per page
Measured result
Recorded before/after benchmark:
| Metric | Before | After | Improvement |
|---|---|---|---|
| HTML footer, no logo | 7.82 s | 1.43 s | 81.7% less time / 5.47× faster |
| HTML footer + logo | 11.81 s | 1.29 s | 89.1% less time / 9.16× faster |
| Footer PDF size | 46.7 MB | ~1.0 MB | ~97.9% smaller |
| No-logo peak heap | 102.6 MB | 26.5 MB | ~74.2% lower |
| No-logo retained heap | 47.0 MB | 1.0 MB | ~97.9% lower |
| Logo peak heap | 57.7 MB | 13.7 MB | ~76.3% lower |
A broader all-features/no-images run moved from approximately:
12.63 s → 1.64 s
which is about 87.0% less generation time in that recorded benchmark pair.
What users gained
- much faster repeated static footer handling
- far smaller PDF output in that pathological footer case
- less repeated Canvas allocation
- less Base64/PNG churn
- dynamic page numbers preserved
- no change to the public footer API
This was the first large clear performance win.
6. Phase 2.2 - clean memory isolation
Early benchmark rows could influence one another because browser caches, native Canvas memory and garbage collection are not deterministic.
Phase 2.2 changed the method:
fresh Chromium context
→ fresh page
→ GC
→ run one scenario
→ cleanup
→ GC
→ wait 500 ms
→ GC
→ wait 2 s
→ GC
→ close page
Why this was important
It separated:
- high temporary memory
- retained JS heap
- previous-test contamination
- actual repeatable trends
It also prevented the project from calling every positive heap delta a “memory leak.”
Important finding
Normal image use was not the primary leak-like signal.
Representative fresh-context results showed that ordinary text/images/header/footer/watermark/compression scenarios retained relatively modest amounts compared with the security scenarios.
The investigation therefore moved away from “images are the problem” and toward encryption behavior.
7. Phase 2.3A - security isolation
Security was split into individual variants:
- security off
- user password only
- user + owner password
- print permission
- copy permission
- all permissions
- Canvas text control
- selectable-text encrypted control
Latest comparable 25-page mixed results
| Scenario | Time | Peak increase | Retained after 2s + GC |
|---|---|---|---|
| Canvas mixed, security off | 4.18 s | 17.6 MB | 5.5 MB |
| Canvas mixed, user password | 4.60 s | 121.4 MB | 84.1 MB |
| Selectable mixed, user password | 2.37 s | 13.3 MB | 11.1 MB |
Security overhead in Canvas for this fixture
Turning on a user password changed:
- time: +10.0%
- peak JS heap: 6.9× the unencrypted run
- retained heap: 15.3× the unencrypted run
The PDF size itself remained about the same.
That was a strong indication that the memory behavior was not caused by a larger visible document.
Permissions were not the main culprit
User-only, owner-password and permission variants produced broadly similar high-retention behavior.
Therefore the problem was not traced to:
permissions: ['print', 'copy']
or owner-password mapping.
The trigger was already present with a basic user password.
8. Selectable-text mode as the lower-raster alternative
For the comparable 25-page encrypted mixed fixture:
| Metric | Encrypted Canvas | Encrypted selectable text | Difference |
|---|---|---|---|
| Time | 4.60 s | 2.37 s | 48.5% less time |
| PDF size | 3.9 MB | 0.4 MB | 89.7% smaller |
| Peak heap | 121.4 MB | 13.3 MB | 89.0% lower |
| Retained heap | 84.1 MB | 11.1 MB | 86.8% lower |
This is not a universal renderer benchmark because Canvas and selectable-text mode produce different kinds of PDF output.
However, it strongly supports the architectural guidance:
For large text-heavy documents whose CSS is supported by the vector renderer, selectable-text mode can be much lighter than raster Canvas mode.
Canvas remains the visual-fidelity option.
9. Phase 2.3B - actual large-PDF correctness
A critical anomaly appeared in long Canvas output.
Earlier observations included:
25 pages → multi-megabyte PDF
50 pages → tens of kilobytes
100 pages → similarly tiny output
That is not normal scaling.
The first question was whether pages were missing.
The PDF Blob was inspected for actual PDF page dictionaries.
Result:
10 requested → 10 actual
25 requested → 25 actual
40 requested → 40 actual
50 requested → 50 actual
75 requested → 75 actual
100 requested → 100 actual
Important conclusion
The PDF could contain the correct number of page objects while raster payload was missing/collapsed.
So a correct page count alone was not enough.
This is why the long-document tests now check multiple signals:
- actual page count
- Blob size
- canvas telemetry
10. Phase 2.4A - security retention scales with raster payload
The protected Canvas memory behavior was measured across page counts.
Latest representative mixed results:
| Pages | Security off retained | User-password retained |
|---|---|---|
| 5 | 2.0 MB | 17.7 MB |
| 10 | 2.9 MB | 34.3 MB |
| 15 | 3.8 MB | 50.9 MB |
| 20 | 4.6 MB | 67.5 MB |
| 25 | 5.4 MB | 84.1 MB |
The encrypted result grows almost linearly with raster payload/page count.
That is much stronger evidence than a single noisy heap sample.
What we learned
The problematic combination is approximately:
large raster/image streams
+
jsPDF encryption
rather than simply:
many PDF pages
or:
Vue component lifecycle
11. Phase 2.4B - long-canvas cliff investigation
During the investigation, long Canvas output showed a sharp collapse around a high page count in the legacy whole-document path.
This was initially treated as a browser/canvas threshold problem and instrumented specifically.
The later Phase 3.5 diagnosis found the more actionable package-level reason for the reintroduced failure:
the incremental Canvas renderer existed, but
generatePdf.tswas no longer routing paginated Canvas documents into it.
That discovery changed the solution from “lower the test threshold” to “restore the correct renderer path.”
The project deliberately kept the existing Blob-size correctness threshold instead of weakening it.
12. Phase 3.1 - page-by-page incremental Canvas rendering
This was the most important architectural change for large Canvas documents.
Old risk
A 50-page diagnostic showed one huge source element:
794 × 55,952 CSS px
with:
html2canvas scale = 1.5
That implies an effective raster geometry around:
1,191 × 83,928 px
≈ 99.96 million pixels
for one giant capture.
New page-bounded geometry
The incremental Canvas benchmark reports a largest encoded page canvas of:
1,191 × 1,461 px
≈ 1.74 million pixels
Derived improvement in capture geometry
Compared with the diagnosed giant 50-page capture:
~99.96M pixels → ~1.74M pixels
That is approximately:
- 57.4× smaller peak raster surface
- 98.26% lower peak canvas pixel area
This is an architectural geometry reduction, not a claim that total browser memory is 98.26% lower.
The browser still has:
- jsPDF data
- decoded images
- JavaScript objects
- encryption buffers
- temporary strings/Blobs
But the largest HTML capture itself is now page-bounded.
Incremental flow
For documents containing manual page-break markers:
Page 1
→ clone fragment
→ html2canvas(page)
→ add to jsPDF
→ remove temporary DOM
→ release canvas
→ yield
Page 2
→ repeat
...
One jsPDF document is kept while page raster resources are short-lived.
Current benchmark correctness
Latest Phase 2.3B-style correctness run on the page-bounded path:
| Requested | Actual | Time | PDF size | Largest canvas |
|---|---|---|---|---|
| 10 | 10 | 1.53 s | 1.5 MB | 1191 × 1461 |
| 25 | 25 | 4.33 s | 3.9 MB | 1191 × 1461 |
| 40 | 40 | 7.54 s | 6.2 MB | 1191 × 1461 |
| 50 | 50 | 9.68 s | 7.8 MB | 1191 × 1461 |
| 60 | 60 | 12.56 s | 9.3 MB | 1191 × 1461 |
| 75 | 75 | 16.87 s | 11.7 MB | 1191 × 1461 |
| 100 | 100 | 25.25 s | 15.8 MB | 1191 × 1461 |
The maximum encoded canvas stayed page-sized while requested pages increased from 10 to 100.
That is exactly the desired property.
13. Phase 3.2 - security re-test after incremental Canvas
Incremental Canvas fixes the giant-canvas problem, but it does not magically eliminate all downstream jsPDF memory behavior.
The security re-test showed:
5p encrypted → ~17.7 MB retained
10p encrypted → ~34.3 MB
15p encrypted → ~51 MB
20p encrypted → ~67.6 MB
25p encrypted → ~84.2 MB
while equivalent unencrypted runs remained much lower.
Conclusion
Page-by-page Canvas rendering solved the package's large-capture architecture problem.
It did not eliminate the encryption/raster retention pattern.
That distinction is important:
- package-level giant Canvas issue: addressed
- dependency-level encrypted raster retention: still relevant
14. Phase 3.2.1 - raw jsPDF reproduction
To determine whether Vue, html2pdf or the component lifecycle was responsible, a minimal raw jsPDF benchmark was created.
No Vue component architecture was required for the core reproduction.
25-page JPEG data-URL result
| Metric | Security off | Security on |
|---|---|---|
| Time | 36.3 ms | 355.2 ms |
| Retained heap | ~0.47 MB | ~93.36 MB |
| Peak increase | ~5.38 MB | ~67.07 MB |
Derived comparison:
- encrypted generation: ~9.8× slower
- retained heap: ~200× larger
- peak increase: ~12.5× larger
25-page PNG data-URL stress result
| Metric | Security off | Security on |
|---|---|---|
| Time | ~4.95 s | ~23.76 s |
| Retained heap | ~125.6 MB | ~2.55 GB |
| Peak increase | ~272 MB | ~2.68 GB |
The encrypted PNG case was an intentionally extreme stress case, but it showed the same dependency-level direction.
Why this was valuable
It demonstrated that the strongest encrypted-raster retention behavior could occur even when the normal Vue component lifecycle was removed from the reproduction.
Therefore the project uses careful wording:
memory retention
rather than claiming a proven permanent Vue memory leak.
An upstream issue was filed:
jsPDF #4017 - encrypted raster PDF memory retention
https://github.com/parallax/jsPDF/issues/4017
15. Phase 3.3 - cancellation
Large browser PDFs need a way to stop.
The component now exposes:
pdfRef.value?.cancelGeneration()
Canvas cancellation
Incremental Canvas checks cancellation around safe checkpoints such as:
- before page work
- around page capture
- before/after expensive encoding boundaries
- between pages
- around browser-yield points
A synchronous operation already executing cannot be interrupted in the middle, but the next page can be prevented from starting.
Selectable-text cancellation
The vector renderer was adapted to cooperate with cancellation during:
- DOM walking
- image materialization
- pagination
- page rendering
- long loops
Recovery is part of the feature
The E2E contract is not merely “AbortError was thrown.”
It verifies:
start long PDF
→ cancel
→ generation reports cancelled
→ no successful Blob for cancelled run
→ temporary rendering DOM is gone
→ start another PDF
→ recovery generation succeeds
Both Canvas and selectable-text recovery paths passed.
User benefit
- accidental 50/100-page export no longer has to run to completion
- UI can provide a meaningful Cancel action
- package does not remain stuck after a cancellation
16. Phase 3.3 - page-aware progress
Long generation now has richer progress.
The public state can include:
{
stage: 'rendering',
progress: 57,
currentPage: 21,
totalPages: 100,
}
Applications can show:
Rendering page 21 of 100
instead of only:
57%
This is particularly useful when individual pages vary in complexity.
17. Cooperative browser yielding
Long PDF generation contains unavoidable synchronous work.
Examples:
- html2canvas operations
canvas.toDataURL()- jsPDF image insertion/serialization
The project therefore did not pretend the entire renderer had become background-thread work.
Instead, cooperative browser yields were introduced between expensive chunks.
Conceptually:
await new Promise((resolve) => requestAnimationFrame(resolve))
Benefit
The browser gets opportunities to:
- repaint progress UI
- process a Cancel click
- update loader state
- avoid one enormous uninterrupted JavaScript loop
This improves perceived responsiveness without changing the public PDF API.
18. Phase 3.4A - repeated image caching in selectable-text mode
Repeated assets are common in PDFs:
- company logos
- product thumbnails
- signatures
- badges
- decorative images
The selectable renderer previously had to materialize image content repeatedly.
A per-generation cache was introduced.
Cache key includes
image source/currentSrc
target raster width
target raster height
format
JPEG quality
Why per-generation only
The cache is cleared after the current PDF.
This avoids creating a global image cache that could keep old customer/document assets alive indefinitely.
Why <canvas> is not cached
A <canvas> can change its pixels while keeping the same dimensions.
Therefore only real <img> sources are safe for this reuse strategy.
Regression fixture
The image-heavy selectable test uses:
4 image elements per page
only 2 actual source URLs
and tests:
10 pages
25 pages
50 pages
The regression bounds image encoding calls to:
toDataURL calls <= 4
Work avoided versus naive per-element encoding
If every image element were encoded separately:
| Pages | Image elements | Encodes bounded by test | Reduction vs naive per-element work |
|---|---|---|---|
| 10 | 40 | ≤4 | ≥90% fewer |
| 25 | 100 | ≤4 | ≥96% fewer |
| 50 | 200 | ≤4 | ≥98% fewer |
This percentage describes the test fixture's avoided repeated encoding work, not a universal whole-PDF speedup.
User benefit
- repeated logos/assets scale much better
- less temporary Canvas encoding work
- less CPU churn
- bounded cache lifetime
19. Phase 3.4B - Canvas cleanup and resource lifetime
The incremental Canvas renderer was tightened so expensive objects do not overlap longer than necessary.
After html2canvas finishes reading a temporary page:
measure links/dimensions
→ remove cloned page DOM
→ remove host
→ yield
After encoding/inserting the raster:
release data URL reference
→ canvas.width = 0
→ canvas.height = 0
→ release canvas reference
→ yield
Why zero the canvas dimensions?
A JavaScript reference becoming null does not guarantee that a browser immediately releases the native Canvas backing allocation.
Resetting:
canvas.width = 0
canvas.height = 0
actively discards the backing store.
User benefit
- shorter overlap between temporary DOM and large raster memory
- lower risk of old page canvases surviving unnecessarily while new pages render
- cleaner cancellation/recovery
20. Phase 3.5 - the 50/100-page routing regression
A later regression was extremely informative.
The project contained:
renderIncrementalCanvasPdf(...)
and:
shouldUseIncrementalCanvasRenderer(...)
but generatePdf.ts had accidentally lost the branch that actually routed paginated Canvas documents into that renderer.
So runtime behavior was effectively:
text → selectable renderer
canvas → legacy whole-document html2pdf worker
even though diagnostics suggested incremental mode should be applicable.
Symptom
50-page Canvas:
element size: 794 × 55,952
one giant html2canvas capture
Blob: ~42,671 bytes
The test expected a meaningful non-empty raster PDF and failed.
Fix
Routing was restored:
text
→ selectable renderer
canvas + page-break markers
→ incremental Canvas renderer
canvas without markers
→ legacy html2pdf worker
Final regression gate
After the routing fix:
50-page Canvas test → PASS (20.1 s)
100-page Canvas test → PASS (32.2 s)
These are release-gate E2E timings for that specific fixture/environment, not universal performance promises.
The important point is:
- no Blob threshold was lowered
- the test did not get weakened
- the renderer path was fixed
21. Final stress results
The latest heavy stress summary included successful cases such as:
| Mode | Pages | Content | Quality | Security | Time | PDF size | Peak heap increase | Retained |
|---|---|---|---|---|---|---|---|---|
| Canvas | 10 | text | 1.5 | off | 1.91 s | 2.3 MB | 15.2 MB | 3.9 MB |
| Canvas | 25 | mixed | 1.5 | on | 4.44 s | 4.0 MB | 24.3 MB | 5.7 MB |
| Text | 10 | text | 1 | off | 1.24 s | 0.2 MB | 6.4 MB | 2.3 MB |
| Text | 25 | mixed | 1 | on | 2.23 s | 0.4 MB | 5.0 MB | 3.4 MB |
| Text | 50 | mixed | 1 | off | 4.42 s | 0.7 MB | 7.1 MB | 4.7 MB |
| Text | 100 | text | 1 | off | 11.97 s | 2.1 MB | 27.5 MB | 10.2 MB |
| Canvas | 50 | mixed | 2 | off | 10.77 s | 11.8 MB | 24.0 MB | 13.6 MB |
| Canvas | 100 | mixed | 2 | on | 28.50 s | 23.7 MB | 43.7 MB | 25.6 MB |
| Canvas | 100 | image-heavy | 2 | off | 21.64 s | 22.3 MB | 46.0 MB | 24.2 MB |
| Text | 100 | image-heavy | 1 | on | 4.89 s | 1.0 MB | 6.9 MB | 5.5 MB |
The repeated cleanup probe in that stress run retained:
2.2 MB across 5 cycles
and reported:
Potential leak signal: no obvious signal
This is separate from the protected-raster security-specific retention investigation.
22. Repeated-generation cleanup result
The latest general Canvas isolation repeated probe ran six generate/cleanup cycles in the same page/context.
Result:
Retained heap across all cycles: 0.3 MB
This is a useful control.
It shows that ordinary repeated generation/cleanup does not automatically produce the same large baseline growth seen in the encrypted-raster scenarios.
Again, that distinction helped isolate the security-specific behavior.
23. Final automated verification status
The final package verification reached:
TypeScript type-check ✅
Production build ✅
19 / 19 unit-test files ✅
106 / 106 unit tests ✅
50-page Canvas regression ✅
100-page Canvas regression ✅
Canvas cancellation + recovery ✅
Selectable-text cancellation + recovery ✅
Selectable image-cache regression: 3 / 3 ✅
Canvas image-heavy cleanup regression: 2 / 2 ✅
General E2E:
44 passed
2 skipped
2 Firefox timeouts under long suite load
Both Firefox failures passed immediately when rerun individually:
footer-logo-disabled Firefox rerun ✅
except-first watermark Firefox rerun ✅
Therefore they were treated as suite-load/timing flakes rather than reproducible functional regressions.
Heavy PDF suites are intentionally run with low worker counts, typically:
--workers=1
to avoid creating artificial browser resource contention.
24. What the performance scripts are for
test:e2e:stress
npm run test:e2e:stress
Runs the heavy Playwright stress matrix.
Why: separates expensive performance/regression work from ordinary UI E2E.
benchmark:pdf
npm run benchmark:pdf
Runs the baseline PDF benchmark.
Why: establishes repeatable generation-time/output/memory data.
benchmark:pdf:heavy
npm run benchmark:pdf:heavy
Adds heavier long-document scenarios.
Why: reveals failures that 10/25-page smoke cases can hide.
benchmark:pdf:headful
npm run benchmark:pdf:headful
Runs with a visible browser.
Why: useful for observing browser responsiveness and comparing headless/headful behavior.
benchmark:summary
npm run benchmark:summary
Builds a human-readable summary from raw benchmark JSON.
Why: makes trend comparison easier and keeps raw evidence reproducible.
benchmark:canvas-isolation
npm run benchmark:canvas-isolation
Tests one Canvas feature at a time.
Why: identified the repeated HTML-footer rasterization bottleneck and later helped separate security from other features.
benchmark:canvas-isolation:headful
Same isolation benchmark in a visible browser.
benchmark:phase2-2
npm run benchmark:phase2-2
Runs fresh-context memory isolation and same-page repeat probes.
Why: distinguishes real trends from previous-test/GC contamination.
benchmark:phase2-2:headful
Visible-browser version of the clean memory isolation run.
benchmark:phase2-3
npm run benchmark:phase2-3
Runs:
- security memory isolation
- large Canvas output correctness
benchmark:phase2-3:security
Runs only the security-isolation side.
Why: compares password/permission variants and renderer controls.
benchmark:phase2-3:canvas
Runs only large Canvas correctness.
Why: checks actual PDF pages, Blob size and maximum raster geometry.
benchmark:phase2-4
Runs:
- security payload scaling
- Canvas-threshold diagnostics
benchmark:phase2-4:security
Runs only security scaling.
Why: demonstrated that retained heap grows with encrypted raster payload/page count.
benchmark:phase2-4:canvas
Runs only the long-Canvas threshold/correctness investigation.
test:e2e:phase3-1
npm run test:e2e:phase3-1
Runs the Phase 3.1 large-document regression matrix.
Why: protects page-by-page Canvas behavior and selectable-mode long-document behavior from future regressions.
benchmark:phase3-2
Re-tests security memory behavior after the incremental Canvas architecture.
Why: verifies what the package fix solved and what remains downstream.
benchmark:phase3-2:repeat
Repeats protected generation in the same browser page.
Why: observes whether encrypted-raster retained memory changes the session baseline over repeated use.
benchmark:phase3-2-1
Runs the raw jsPDF reproduction.
Why: removes Vue/component lifecycle from the strongest security-memory reproduction and provides upstream evidence.
25. What was actually improved in production code
The benchmark program resulted in production changes, not only test files.
Production improvements
1. Static footer reuse
Before:
same HTML footer rasterized on every page
After:
static footer rasterized once and reused
2. Incremental page-by-page Canvas rendering
Before risk:
entire long document → one giant html2canvas capture
After for paginated Canvas:
one page → capture → add → cleanup → next page
3. Temporary DOM cleanup
Off-screen page DOM is removed as soon as html2canvas no longer needs it.
4. Canvas backing-store release
After page insertion:
canvas.width = 0
canvas.height = 0
5. Data URL lifetime reduction
Temporary encoded page strings are not intentionally retained after insertion.
6. Selectable repeated-image cache
Repeated real <img> sources reuse materialized raster data within one generation.
7. Cache cleanup
The image cache is cleared at the end of the generation phase.
8. Browser yielding
Long loops periodically yield to the browser.
9. Cancellation
Both renderer families have cooperative abort support.
10. Page-aware progress
currentPage / totalPages are available in page-aware rendering.
11. Recovery verification
A cancelled generation is followed by a successful new generation in E2E.
12. Long-document routing protection
generatePdf.ts explicitly routes paginated Canvas documents into the incremental renderer.
26. Goal-by-goal completion matrix
| Original goal | Status | Result |
|---|---|---|
| Benchmark 10 / 25 / 50 / 100 pages | ✅ Completed | Repeatable stress and correctness matrices |
| Measure generation time | ✅ Completed | JSON + Markdown summaries |
| Measure PDF size | ✅ Completed | Blob-size tracking |
| Measure browser memory | ✅ Completed | Peak/retained JS heap probes |
| Detect repeated memory growth | ✅ Completed | Fresh-context and same-page repeat probes |
| Reduce giant Canvas pressure | ✅ Completed | Page-by-page incremental Canvas |
| Explore page-by-page rendering | ✅ Completed | Production renderer implemented |
| Improve image reuse | ✅ Completed | Per-generation selectable image cache |
| Add cancellation | ✅ Completed | Canvas + selectable cancellation/recovery |
| Add current/total page progress | ✅ Completed | PdfProgressState page fields |
| Improve responsiveness | ✅ Completed | Cooperative browser yields |
| Expand large-document E2E | ✅ Completed | Phase 3 regression suites |
| Verify cleanup | ✅ Completed | DOM/canvas/cache/cancel cleanup assertions |
| Isolate encrypted memory behavior | ✅ Completed | Package + raw jsPDF reproduction |
| File upstream dependency issue | ✅ Completed | jsPDF #4017 |
| Warn user before risky document | ⏳ Not implemented as a public warning API | Documentation guidance exists |
| Web Worker whole renderer | ❌ Not implemented | DOM/html2canvas cannot simply move wholesale to a Worker |
| Direct structured-PDF API | ❌ Deliberately deferred | Package philosophy remains HTML/Vue-first |
This distinction matters: the project should not claim features that were only discussed but never shipped.
27. Safe performance claims for README/release notes
These statements are supported by the recorded work:
Safe claim 1
Large paginated Canvas documents use page-by-page rendering instead of requiring one full-document raster capture.
Safe claim 2
The large-document regression suite covers real 50-page and 100-page Canvas output.
Safe claim 3
Static HTML footers are rasterized once and reused across pages.
Safe claim 4
The recorded footer bottleneck benchmark improved from 7.82 s to 1.43 s without a logo and from 11.81 s to 1.29 s with a logo.
Safe claim 5
Repeated selectable-mode image assets are cached per generation to avoid unnecessary repeated encoding.
Safe claim 6
Long generation supports cooperative cancellation and page-aware progress.
Safe claim 7
Browser memory behavior is stress-tested with fresh-context and repeated-generation probes.
Safe claim 8
Encrypted raster PDFs are documented as a higher-memory workload; the dependency-level retention pattern was reproduced with raw jsPDF and reported upstream.
28. Claims that should NOT be made
Do not publish claims such as:
"100 pages will work on every device"
"the package has zero memory leaks"
"memory use is 98% lower overall"
"the package is 80% faster overall"
"encryption is fully low-memory"
"Canvas mode never freezes the browser"
Why?
Because:
- arbitrary HTML complexity differs
- device memory differs
- image dimensions differ
- Canvas scale differs
- browser GC differs
- encrypted raster retention remains dependency-sensitive
The 98.26% figure in this report refers specifically to the largest raster capture geometry in the diagnosed giant-canvas case versus the page-bounded capture, not total end-to-end browser memory.
29. Practical guidance for users
Prefer Canvas when
- CSS fidelity is the priority
- gradients/shadows/transforms matter
- browser-like appearance matters more than selectable body text
For long Canvas documents:
- use deterministic page boundaries
- keep
pdfQualityreasonable - avoid enormous source images
- generate one very large PDF at a time
- provide a Cancel button
- test encryption on representative hardware
Prefer selectable text when
- real selectable/searchable/copyable text matters
- the document is text-heavy
- supported vector CSS is sufficient
- lower raster workload is desirable
For repeated assets
Use normal image URLs/data URLs consistently so per-generation reuse can work.
30. Final outcome
The large-document milestone achieved the original core objective:
Make large PDF generation safer, more measurable, more interruptible and more reliable without replacing the existing Vue HTML-to-PDF developer experience.
The package now has:
two rendering strategies
+
page-by-page Canvas for paginated long documents
+
selectable/vector rendering
+
image reuse
+
cancellation
+
page progress
+
browser yielding
+
cleanup discipline
+
memory isolation benchmarks
+
50/100-page correctness gates
+
upstream dependency diagnostics
The most important transformation is architectural:
BEFORE
long paginated Canvas document
→ risk of one enormous full-document capture
AFTER
long paginated Canvas document
→ render one logical page
→ add it
→ release temporary resources
→ continue
And the most important measurable proof is:
50-page Canvas regression: PASS
100-page Canvas regression: PASS
largest encoded page canvas remains 1191 × 1461 in the benchmark matrix
The project did not solve every browser/PDF memory problem, and it should not claim that it did.
What it did achieve is stronger:
- real bottlenecks were measured
- one major footer bottleneck was removed
- long Canvas capture architecture was improved
- the 50/100-page collapse regression was fixed correctly
- repeated-image work was bounded
- users gained cancellation and meaningful page progress
- memory cleanup was made more deliberate
- the remaining encrypted-raster retention issue was isolated below the Vue layer and reported upstream
That makes vue-pdf-export substantially more credible for serious multi-page browser PDF workloads than it was before this performance milestone.
Appendix A - headline measured improvements
| Improvement | Recorded result |
|---|---|
| HTML footer, no logo | 7.82 s → 1.43 s (81.7% less time) |
| HTML footer + logo | 11.81 s → 1.29 s (89.1% less time) |
| Pathological footer PDF size | ~46.7 MB → ~1 MB (~97.9% smaller) |
| No-logo footer peak heap | 102.6 MB → 26.5 MB (~74.2% lower) |
| Giant Canvas raster geometry | ~99.96M px → ~1.74M px/page (~98.26% smaller peak capture surface) |
| Selectable image fixture, 50p | 200 image elements → ≤4 encode calls (≥98% fewer than naive per-element encoding) |
| Encrypted selectable vs encrypted Canvas, 25p mixed | ~86.8% lower retained heap in the recorded comparison |
| 50-page Canvas release regression | PASS |
| 100-page Canvas release regression | PASS |
| Unit suite | 106 / 106 PASS |
| Canvas cancellation/recovery | PASS |
| Selectable cancellation/recovery | PASS |
Appendix B - known limitation
Large encrypted Canvas/raster PDFs remain a special high-memory case.
Upstream tracking:
jsPDF issue #4017
https://github.com/parallax/jsPDF/issues/4017
The package documentation should continue to recommend representative testing for:
- encrypted reports
- image-heavy reports
- very high Canvas quality
- lower-memory devices
- repeated generation in one browser session
Appendix C - one-command regression workflow
The project now supports a reproducible high-level performance run:
npm run performance:everything
The run is intentionally sequential for heavy PDF work.
Running many long-PDF browser jobs in parallel would measure resource contention as much as it measures the package.
For release-critical long-document E2E, keep:
--workers=1
where appropriate.
End of report.
Performance Engineering
The large-document milestone was not only a benchmark exercise. It changed the runtime architecture and added repeatable correctness, cleanup and memory diagnostics.
Community & Feedback
The package source is currently maintained privately, but feedback and feature requests are welcome.
