MeldUI

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:

  1. Plugin registry — pure data describing which plugins are registered and how they’re configured (pluginRegistry.ts)
  2. 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 features flags you set. Vue’s setup() must call composables unconditionally, and EmbedPDF composables throw when their plugin isn’t registered — so the registry keeps them all on. The features prop 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 registeredstamps, signature, redaction, forms, and attachments are 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 packageToolbar/UX gated by
@embedpdf/plugin-zoomfeatures.zoom
@embedpdf/plugin-rotatefeatures.rotate
@embedpdf/plugin-spreadfeatures.spread
@embedpdf/plugin-panfeatures.pan
@embedpdf/plugin-fullscreenfeatures.fullscreen
@embedpdf/plugin-interaction-manager(always; bridges pointer events)
@embedpdf/plugin-selectionfeatures.selection
@embedpdf/plugin-searchfeatures.search
@embedpdf/plugin-bookmarkfeatures.outline
@embedpdf/plugin-thumbnailfeatures.thumbnails
@embedpdf/plugin-exportfeatures.download + saveAsCopy()
@embedpdf/plugin-printfeatures.print
@embedpdf/plugin-commandsfeatures.keyboardShortcuts
@embedpdf/plugin-historyfeatures.undoRedo
@embedpdf/plugin-annotationfeatures.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 flagPlugin 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 profileApprox. 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 features flags 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.vue actually mounts (because PdfViewer is wrapped in defineAsyncComponent)
  • Cached aggressively — serve it with Cache-Control: public, max-age=31536000, immutable and 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