Document Viewer: Bundle & Performance
Why DocumentViewer is built headless, the per-feature plugin set, expected bundle sizes, and WASM hosting strategy.
DocumentViewer is built on EmbedPDF’s headless plugin architecture. This page explains what that means for your bundle size and load time, plus the patterns the composite uses to keep the PDF code path off the critical render path.
Headless rationale
EmbedPDF separates concerns into two layers:
- Plugin registry — pure data describing which plugins are registered and how they’re configured (
pluginRegistry.ts) - Vue host — the Vue components that mount EmbedPDF inside
<Suspense>and provide reactive bindings
How registration actually works in DocumentViewer is a deliberate trade-off (see Plugin reference → registration pattern):
- All Phase 1 plugins are always registered, regardless of which
featuresflags you set. Vue’ssetup()must call composables unconditionally, and EmbedPDF composables throw when their plugin isn’t registered — so the registry keeps them all on. Thefeaturesprop controls toolbar / UX visibility, not plugin presence. This also means the programmatic API (e.g. annotation CRUD) is available even when its toolbar tools are hidden. - Only Phase 2 plugins are conditionally registered —
stamps,signature,redaction,forms, andattachmentsare registered only when their flag is enabled, so those packages tree-shake out when unused.
In other words: turning a Phase 1 features flag off hides UI but does not remove a plugin package from your bundle. True Phase 1 plugin-level tree-shaking would require a custom registry function.
Per-feature plugin set
The minimum required set is always registered (zero opt-in needed):
@embedpdf/plugin-document-manager
@embedpdf/plugin-viewport
@embedpdf/plugin-scroll
@embedpdf/plugin-render
@embedpdf/plugin-tiling
Phase 1 — always registered (flag gates toolbar/UX only)
These plugins are registered unconditionally; the listed features flag toggles the corresponding toolbar / overlay, not whether the package ships:
| Plugin package | Toolbar/UX gated by |
|---|---|
@embedpdf/plugin-zoom | features.zoom |
@embedpdf/plugin-rotate | features.rotate |
@embedpdf/plugin-spread | features.spread |
@embedpdf/plugin-pan | features.pan |
@embedpdf/plugin-fullscreen | features.fullscreen |
@embedpdf/plugin-interaction-manager | (always; bridges pointer events) |
@embedpdf/plugin-selection | features.selection |
@embedpdf/plugin-search | features.search |
@embedpdf/plugin-bookmark | features.outline |
@embedpdf/plugin-thumbnail | features.thumbnails |
@embedpdf/plugin-export | features.download + saveAsCopy() |
@embedpdf/plugin-print | features.print |
@embedpdf/plugin-commands | features.keyboardShortcuts |
@embedpdf/plugin-history | features.undoRedo |
@embedpdf/plugin-annotation | features.annotations |
commentThreads adds no plugin — it’s a pure overlay keyed by annotation id and requires features.annotations. touchGestures is also plugin-free (pure DOM listeners).
Phase 2 — conditionally registered (tree-shaken when off)
| Feature flag | Plugin package |
|---|---|
stamps | @embedpdf/plugin-stamp |
signature | @embedpdf/plugin-signature |
redaction | @embedpdf/plugin-redaction |
forms | @embedpdf/plugin-form |
attachments | @embedpdf/plugin-attachment |
See Getting Started → Install for the install set.
Expected bundle sizes
Approximate gzipped JS contributions (excluding the WASM binary, which loads separately):
| Feature profile | Approx. gzipped JS |
|---|---|
| Minimum (read-only PDF, no opt-ins) | ~110 KB |
| Read-only + zoom + search + download | ~140 KB |
| Review (above + outline + thumbnails + annotations + commentThreads + undoRedo) | ~200 KB |
| Kitchen-sink (all Phase 1 flags on) | ~240 KB |
| Kitchen-sink + all Phase 2 flags | ~310 KB |
Note: Because all Phase 1 plugins are always registered, toggling Phase 1
featuresflags off does not meaningfully shrink the JS bundle — the rows above mainly differ once Phase 2 flags (stamps / signature / redaction / forms / attachments) enter the mix. Treat these as rough order-of-magnitude figures; measure your own build for exact numbers.
The big constants beyond the headless plugins:
@embedpdf/engines~25 KB@embedpdf/core~30 KB@embedpdf/models~10 KB
These ship regardless of which feature flags you enable.
The WASM binary
pdfium.wasm is ~4.5 MB on disk, ~1.6 MB gzipped over the wire. It’s:
- Loaded on demand — only when
DocumentViewer.vueactually mounts (becausePdfVieweris wrapped indefineAsyncComponent) - Cached aggressively — serve it with
Cache-Control: public, max-age=31536000, immutableand the user pays the cost once - Off the critical path — the toolbar and chrome render while the WASM is decoding; the user sees the viewer shell immediately
Self-host it via your build script (see Getting Started).
Lazy code-splitting
The composite wraps the PDF renderer in defineAsyncComponent:
const PdfViewer = defineAsyncComponent(() => import('./renderers/PdfViewer.vue'))
This produces a separate Vite/Rolldown chunk for the EmbedPDF-dependent code. Apps that bundle DocumentViewer but only ever render images / text / markdown do not pay the PDF code path’s bytes until a PDF is first opened.
The image, text, and markdown renderers are tiny (each under 2 KB) and ship eagerly.
Worker vs. main thread
PDFium runs inside a Web Worker by default (the worker prop defaults to true), keeping the main thread responsive on large documents.
If your app hits the worker engine’s relative-wasmUrl resolution issue (the worker is built from a blob URL whose base differs from the page’s), opt back to main-thread mode:
<DocumentViewer :worker="false" wasm-url="https://your-cdn.com/pdfium.wasm" ... />
Fully-qualified URLs (https://...) and absolute paths (/pdfium.wasm) work with either mode.
Virtualization
DocumentViewer uses EmbedPDF’s tiling + viewport plugins to render only the pages currently in the viewport plus a small overscan. Documents with 1000+ pages render with constant memory.
Bundle audit
Audit your bundle’s plugin chunks:
pnpm --filter your-app build
# Look at the dist for chunks named like:
# - vendor/embedpdf-core-*.mjs
# - vendor/embedpdf-plugin-zoom-*.mjs ← always present (Phase 1, always registered)
# - vendor/embedpdf-plugin-stamp-*.mjs ← absent unless features.stamps = true
# - vendor/embedpdf-plugin-signature-*.mjs ← absent unless features.signature = true
All Phase 1 plugin chunks ship regardless of features flags (the flags gate UI, not registration). Only the Phase 2 plugins — stamp, signature, redaction, form, attachment — are tree-shaken out when their flag is off.
See also
- Getting Started → Install
- Plugin reference — each plugin’s role and dependencies
- Use case: large documents
- Troubleshooting — WASM 404s, worker issues, large file timeouts