PDF.js tutorial: A complete guide with examples
Table of contents
To render a PDF with PDF.js(opens in a new tab), import its display library, configure a matching worker, load the document, and await each page’s render task. The complete example below adds page navigation, zoom, and visible errors. For search, selectable text, forms, and annotation editing, use the bundled viewer or integrate its additional layers.
What is PDF.js?
PDF.js is Mozilla’s open source PDF renderer, licensed under Apache 2.0. You can build a custom viewer with its display API or host its complete viewer. A canvas example only draws the page; it doesn’t automatically include the viewer’s toolbar, text layer, or annotation editor.
The project separates PDF parsing, the display API, and the viewer UI. The worker handles much of the parsing work, while the browser draws pages. Large documents still need careful memory management and testing on your target devices.
Getting started with PDF.js
Create a directory and install the version used in this example:
mkdir pdfjs-viewercd pdfjs-viewernpm init -ynpm install pdfjs-dist@6.3.289Save a PDF as document.pdf in this directory. You can use our sample PDF. Keep the library, worker, fonts, character maps, and WebAssembly resources from the same package version. The paths below serve them directly from node_modules for a local demonstration; copy the required resources into your public assets directory for deployment.
Create the viewer page
Save this as index.html. The controls stay disabled until the first page finishes rendering, and a download link remains available if rendering fails:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>PDF.js viewer</title> <style> body { margin: 1rem; font-family: sans-serif; } .toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; } #viewport { overflow: auto; margin-top: 1rem; } canvas { display: block; } </style> </head> <body> <div class="toolbar" aria-label="PDF controls"> <button id="previous" disabled>Previous page</button> <button id="next" disabled>Next page</button> <button id="zoom-out" disabled>Zoom out</button> <button id="zoom-in" disabled>Zoom in</button> <span id="page-number"></span> </div> <p id="status" role="status">Loading PDF…</p> <a href="document.pdf">Open or download the original PDF</a> <div id="viewport"> <canvas id="pdf-canvas" aria-label="PDF page preview"></canvas> </div> <script type="module" src="index.js"></script> </body></html>Load the document and render one page at a time
Save this as index.js. It uses explicit module imports, waits for rendering to finish, and disables controls during a render. That prevents two render tasks from drawing onto the same canvas:
import { getDocument, GlobalWorkerOptions,} from "./node_modules/pdfjs-dist/build/pdf.mjs";
const assets = new URL("./node_modules/pdfjs-dist/", import.meta.url);GlobalWorkerOptions.workerSrc = new URL("build/pdf.worker.mjs", assets).href;
const canvas = document.getElementById("pdf-canvas");const context = canvas.getContext("2d");const status = document.getElementById("status");const previous = document.getElementById("previous");const next = document.getElementById("next");const zoomOut = document.getElementById("zoom-out");const zoomIn = document.getElementById("zoom-in");let pdf;let pageNumber = 1;let scale = 1;let busy = false;
function updateControls() { previous.disabled = busy || !pdf || pageNumber === 1; next.disabled = busy || !pdf || pageNumber === pdf.numPages; zoomOut.disabled = busy || !pdf || scale <= 0.5; zoomIn.disabled = busy || !pdf || scale >= 3;}
async function renderPage() { if (busy || !pdf) return; busy = true; updateControls(); status.textContent = "Rendering…"; try { const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale }); const outputScale = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.floor(viewport.width * outputScale); canvas.height = Math.floor(viewport.height * outputScale); canvas.style.width = `${viewport.width}px`; canvas.style.height = `${viewport.height}px`; await page.render({ canvasContext: context, viewport, transform: [outputScale, 0, 0, outputScale, 0, 0], }).promise; document.getElementById("page-number").textContent = `Page ${pageNumber} of ${pdf.numPages}`; status.textContent = `Zoom ${Math.round(scale * 100)}%`; page.cleanup(); } catch (error) { console.error(error); status.textContent = "Could not render this page. Try the original PDF link."; } finally { busy = false; updateControls(); }}
previous.addEventListener("click", () => { if (busy || !pdf || pageNumber <= 1) return; pageNumber -= 1; void renderPage();});next.addEventListener("click", () => { if (busy || !pdf || pageNumber >= pdf.numPages) return; pageNumber += 1; void renderPage();});zoomOut.addEventListener("click", () => { if (busy || !pdf) return; scale = Math.max(0.5, scale - 0.25); void renderPage();});zoomIn.addEventListener("click", () => { if (busy || !pdf) return; scale = Math.min(3, scale + 0.25); void renderPage();});
try { pdf = await getDocument({ url: "document.pdf", cMapUrl: new URL("cmaps/", assets).href, standardFontDataUrl: new URL("standard_fonts/", assets).href, wasmUrl: new URL("wasm/", assets).href, }).promise; await renderPage();} catch (error) { console.error(error); status.textContent = "Could not load the PDF. Check its URL and permissions.";}Serve the directory over HTTP rather than opening it through file://:
npx serve . -l 8080Open http://localhost:8080. Try the first and last pages, repeated navigation, both zoom limits, and a missing PDF. The scrollable preview prevents a wide PDF from widening the entire webpage on mobile.

The existing recording illustrates navigation and zoom. Its appearance may differ from the controls above.
Text selection, annotations, and forms
Choose the integration level before adding features. Mozilla’s examples(opens in a new tab) and API reference(opens in a new tab) describe the display API; the complete viewer includes additional layers and UI behavior.
| Requirement | What to use |
|---|---|
| Draw a page | The display API and a canvas, as above |
| Extract text | PDFPageProxy.getTextContent(); extraction doesn’t create a selectable text layer |
| Select and copy text onscreen | A text layer aligned with the page viewport, or the complete viewer |
| Inspect annotations | PDFPageProxy.getAnnotations(); returned data isn’t a ready-made editing UI |
| Fill supported forms or edit annotations | The viewer’s form and annotation layers, with the relevant storage and editor integration |
| Search and thumbnails | The complete viewer or your own UI built around the APIs |
PDF coordinates and browser coordinates differ. Don’t place annotation rectangles directly on a canvas using raw PDF coordinates: Rotation, scale, and the different coordinate origins affect placement. Use the viewer’s annotation layer or the viewport’s conversion helpers when building overlays.
A canvas has no selectable text or document reading order. Adding an accessible label to the preview doesn’t make the PDF accessible. Test keyboard navigation, focus, text layers, and assistive technology with representative documents.
Can PDF.js save annotations and form changes?
Yes. The bundled viewer can download supported annotation and form changes, and the display API exposes PDFDocumentProxy.saveDocument(). An editing UI can use HTML overlays and still serialize supported changes into PDF bytes.
A custom integration must connect its edits to the document’s annotation storage before saving. Calling saveDocument() doesn’t invent annotations from unrelated DOM elements. Test exported files in other readers, especially if you rely on particular form fields or annotation types.
PDF.js isn’t a general document-authoring SDK. Don’t equate a drawn signature with certificate-based digital signing or covering text with permanent redaction.
Troubleshooting PDF.js
| Symptom | What to check |
|---|---|
| Worker or module fails to load | HTTP status, JavaScript MIME type, resource paths, and content security policy |
| API and worker versions differ | Deploy both files from the same pdfjs-dist release; clear stale caches |
| An external PDF fails | Cross-origin resource sharing (CORS), credentials, and the PDF server’s response |
| Canvas-in-use error | Await or cancel the previous render task before reusing its canvas |
| Text can’t be selected | Add a text layer or use the complete viewer |
| Blurry pages | Match canvas backing dimensions to device pixel ratio; cap resolution to control memory use |
| Password-protected PDF won’t open | Add an appropriate password prompt; this minimal example reports the load failure |
Serve only the assets your application needs in production. For a single-page application, release loading tasks and documents when removing the viewer. Test the lifecycle of that integration separately from this static-page example.
When to use PDF.js vs. Nutrient Web SDK
PDF.js fits applications that can maintain their own integration or use Mozilla’s viewer. It supports more than passive viewing, including supported form filling and annotation editing.
Nutrient Web SDK is a commercial alternative that ships these capabilities as product features rather than integration work:
- Annotations — 20+ annotation types out of the box, including highlights, ink, stamps, notes, shapes, and measurement tools, with a built-in UI and XFDF/Instant syncing.
- Forms — Form fields are interactive by default, and values are read and written through a direct API rather than by iterating page annotations.
- Signatures and redaction — Digital signatures for cryptographic signing, and redaction that removes content from the file.
- Performance — Progressive loading, render prioritization, and memory management are handled automatically, with no worker or range-request configuration for standard use cases.
- Compliance and review workflows — Document comparison shows the differences between two versions; audit trails record views, annotations, and signatures; and the viewer targets WCAG 2.2 AA. An AI assistant can summarize, classify, and extract data from document content.
- Support — Commercial technical support with service-level agreements. PDF.js has community support only.
Optical character recognition (OCR), Office file rendering, and real-time collaboration are available through the relevant product components and configuration.
For a feature comparison, see PDF.js vs. Nutrient. For a migration path from an existing PDF.js integration, see the migration guide. For a trial, use the demo or contact Sales.
Related reading
- When to upgrade from PDF.js
- Print PDFs with PDF.js
- Build a JavaScript PDF viewer
- Compare JavaScript PDF viewers
- Migrate from PDF.js to Nutrient
FAQ
Yes. PDF.js uses the Apache 2.0 license. Hosting, application integration, and maintenance remain your responsibility.
The worker handles PDF processing away from the main browser thread. Set its path explicitly and use the same version as the display library.
No. A canvas draws pixels. Use a text layer or the complete viewer for text selection and copying.
The viewer and save API support saving certain annotations and form edits. They don’t provide arbitrary editing of all existing PDF content. Validate your specific workflow and output files.
PDF.js renders PDFs. Convert other file formats to PDF first, or use a viewer that supports the required formats.
Nutrient Web SDK is a commercial JavaScript library for viewing and editing documents in the browser. It provides annotations with 20+ types, form filling, digital signatures, and redaction through a documented API, with a built-in viewer UI.
The migration guide covers viewer setup, search, annotations, forms, and thumbnails, with side-by-side code comparisons for each step.
Try the demo to evaluate it against your own documents, or contact Sales to discuss licensing and deployment.