Requirements
The browser floor is set by pdf.js 6. Its modern build calls recent built-ins without polyfills: Promise.withResolvers, Promise.try, URL.parse, iterator helpers, Map.prototype.getOrInsertComputed, Math.sumPrecise and Uint8Array.fromBase64. The library loads that modern build, not pdf.js’s legacy/ build, so older browsers are not supported.
The library itself relies on ResizeObserver, IntersectionObserver, module Web Workers and the scrollend event. createImageBitmap (bitmap cache, thumbnails) and OffscreenCanvas (text measuring) are feature-detected and skipped when missing. Copy actions use the async Clipboard API, which browsers only allow in secure contexts (HTTPS or localhost).
The pdf.js worker
pdf.js parses PDFs in a Web Worker. The first time a document loads, loadPdfJs() (in core/document/pdfjs.ts) does the following:
- It imports
pdfjs-distand checksconfigurePdf. AworkerPortyou passed is used as is. OtherwiseworkerSrcis set to your URL, or topdfjs-dist/build/pdf.worker.min.mjs?url. That?urlimport tells Vite to emit the worker as a hashed asset. - It starts one
PDFWorkerand shares it across every document (sharedWorker, on by default).
The worker is about 1.26 MB minified, or about 375 KB gzipped. In a production build it is a separate file (_app/immutable/assets/pdf.worker.min.<hash>.mjs in SvelteKit). It is only fetched when the first document loads.
Paper.Root / PaperState starts a second pdf.js worker while it analyses a paper. It loads a copy of the document there, so reading operator lists for every page never delays page rendering. The worker comes from the same workerSrc and is destroyed when analysis ends. With new PaperState({ isolate: false }), analysis runs on the shared worker instead.
Self-hosting the worker
With Vite you can leave this alone. To serve the worker from a fixed path, copy node_modules/pdfjs-dist/build/pdf.worker.min.mjs into static/ and point to it before the first document loads:
Keep the copied worker on the same version as the installed pdfjs-dist, because pdf.js refuses a worker from another version. A cross-origin workerSrc (a CDN, for example) still works: pdf.js wraps it in a blob: module that imports the URL.
Your own worker
Pass a readyWorker with configurePdf({ workerPort }): every document shares it. Paper analysis normally runs on a second worker so it doesn't delay rendering; with a workerPort it runs on yours instead.Vite: optimizeDeps.exclude (required)
Add this to your Vite config:
It only affects the dev server. Vite pre-bundles Svelte libraries from node_modules, and its pre-bundler can’t load the worker’s ?url import, so vite dev stops with Could not load …/pdf.worker.min.mjs?url. Excluding pdfjs-dist keeps that import out of pre-bundling, and Vite’s normal pipeline handles it. pdfjs-dist ships as one self-contained ES module, so pre-bundling it gains nothing anyway. Production builds work with or without the exclude.
Other bundlers
Webpack, Rollup, esbuild and similar bundlers work if they can:
- Compile Svelte from
node_modules.dist/ships.sveltecomponents and.svelte.jsrune modules. Every entry exceptsvelte-pdf-mini/coreneeds the Svelte compiler, throughsvelte-loader,rollup-plugin-svelteoresbuild-svelte. Resolving with thesvelteexport condition is recommended.svelte-pdf-mini/coreis plain TypeScript output. - Handle the worker’s
?urlimport. It is Vite syntax, and it is reached from bothsvelte-pdf-miniandsvelte-pdf-mini/core. Map it to an emitted asset, or setworkerSrcyourself so the import never runs. In webpack 5:
- Handle the stylesheet the notes renderer imports. It lazily imports
katex/dist/katex.min.css, so your bundler needs a CSS loader for dynamic CSS imports. At runtime a failed import is ignored and maths renders without KaTeX styles, but the bundler still has to resolve the import at build time.
SSR and prerendering
No setup is needed in SvelteKit, including adapter-static. This site is fully prerendered.
- On the server, components render their static shell: the root elements, data attributes and your snippets, with
statusset to'idle'. - Loading starts in an
$effect, which only runs in the browser.loadPdfJs()rejects outside a browser (typeof window === 'undefined'), and the worker, rendering, observers and paper analysis are all started from effects. pdfjs-distis only imported dynamically, from the browser: its main module (~430 KB minified, ~130 KB gzipped) is a lazy chunk fetched when the first document loads, and the worker is fetched on demand too.
Lazy-loaded dependencies
A plain viewer never downloads pdf-lib (~580 KB minified) or KaTeX (~260 KB minified). Your bundler splits them into their own chunks.
Remote PDFs and CORS
pdf.js fetches URLs from the browser, so the PDF host has to allow your origin through CORS. You can pass a string or URL as src, or { url, httpHeaders, withCredentials } for authenticated requests.
- Range requests. pdf.js loads large files in chunks only when it can read the
Accept-Ranges: bytesandContent-Lengthresponse headers. Cross-origin,Accept-Rangeshas to be listed inAccess-Control-Expose-Headers. Otherwise pdf.js streams the whole file, which still renders progressively. - arXiv sends
Access-Control-Allow-Origin: *but does not exposeAccept-Ranges, so arXiv PDFs are streamed in full. That’s why the examples on this site can load them directly. - Hosts without CORS need a same-origin proxy. Nothing is built in for PDFs. Pass the proxied URL as
src:
To skip range requests entirely, for example behind a proxy that mishandles them, pass pdf.js options: documentOptions={{ disableRange: true, disableStream: true }} on Document.Root, or configurePdf({ documentOptions }) for every document.
The citation metadata providers are separate from PDF loading. OpenAlex, Semantic Scholar and Crossref send CORS headers. The arXiv API does not, so arxiv({ proxy }) takes a URL prefix or a (url) => string rewrite. Every provider also accepts a custom fetch. See Research papers.
Content Security Policy
If your site sends a CSP, allow:
worker-src 'self'for the bundled or self-hosted worker. Addblob:ifworkerSrcis cross-origin, because pdf.js wraps it in ablob:module.connect-srcfor the hosts your PDFs come from, plushttps://cdn.jsdelivr.net. Character maps, standard fonts and WASM decoders load from jsDelivr by default. To keep everything same-origin, self-host them withconfigurePdf({ cMapUrl, standardFontDataUrl, wasmUrl, iccUrl }); see Installation. Also add the citation provider APIs if you use them.