Building a PDF viewer with pdf.js: what I learned

October 13, 2026 · 6 min read · react, pdfjs, bece-vault

Nearly everything on BECE Vault is a PDF: lecture notes, scanned handwritten notes, past papers, lab reports. For a while I just linked to the files and let the browser open them. That works, sort of. Every browser shows PDFs a bit differently, the experience on phones is clumsy, and none of them can do something that matters a lot when you're studying from a 150-page set of notes: reopen the file where you stopped.

So I built my own viewer on top of pdf.js, the same library Firefox uses for its built-in PDF viewer. Here's how it works and what surprised me along the way.

What pdf.js actually does

pdf.js does two different jobs. It parses the PDF, giving you the pages, their sizes and their text. And it renders a page onto a <canvas> at whatever size you ask for.

The simplest possible viewer loads the document and draws every page into its own canvas:

import * as pdfjs from 'pdfjs-dist';

const doc = await pdfjs.getDocument({ url: fileUrl }).promise;

for (let n = 1; n <= doc.numPages; n++) {
  const page = await doc.getPage(n);
  const viewport = page.getViewport({ scale: 1.5 });
  const canvas = document.createElement('canvas');
  canvas.width = viewport.width;
  canvas.height = viewport.height;
  await page.render({ canvas, viewport }).promise;
  container.appendChild(canvas);
}

That's fine for a five-page file. Try it with a 200-page scanned notebook on a phone and the page freezes, because every single page gets rendered at once and the phone runs out of memory.

Only drawing what's on screen

The fix is to render lazily. First the viewer asks pdf.js for the size of every page, which is cheap, and lays out empty placeholders at the right sizes. That way the scrollbar is correct from the start. Then each placeholder watches itself with an IntersectionObserver, and a page only gets drawn when it scrolls near the screen.

const observer = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting) renderPage();   // draw the canvas
  else releasePage();                        // free it again once far away
}, { rootMargin: '600px 0px' });             // start a little before it's visible
observer.observe(placeholder);

Releasing pages turned out to matter just as much as drawing them. Once a page scrolls far enough away, its canvas is thrown out, so you can scroll through a huge document without the memory use climbing forever.

Keeping text sharp

A canvas drawn at "100%" looks blurry on most phones, because their screens pack two or three real pixels into every CSS pixel. So the viewer multiplies its render scale by window.devicePixelRatio. I cap that at 2, though. Drawing a full page at 3× makes a very large image, and the difference is hard to see.

Selecting and searching text

A canvas is just pixels, so on its own you can't select anything. pdf.js can add a text layer on top: invisible text, positioned exactly over the words on the page. That's what makes copy and paste work, and it's also what search is built on. The viewer reads each page's text with getTextContent(), finds the matches, and highlights the matching bits of that text layer.

Around that, it has the things you'd expect from a viewer: zoom (including Ctrl + scroll and pinch on a touchpad), fit to width, fit to page, rotation, fullscreen and printing. None of them were hard on their own. Together they're what make it feel like a real viewer and not a demo.

Highlights that stay put

Highlighting was trickier than I expected. A highlight drawn at 150% zoom has to land in exactly the same spot at 80%, and again after you rotate the page.

What finally worked was storing highlights as fractions of the page, measured at no rotation, instead of in pixels. A highlight is saved as something like "from 12% to 48% across, and 30% to 34% down". Whenever the page is shown at any zoom or angle, those numbers get converted back into screen positions.

The blank pages

This one took me the longest. Some PDFs, mostly scanned handwritten notes, opened as a stack of completely white pages. No error in the console, nothing. Just blank.

It turns out scanned PDFs are often compressed with formats like JBIG2 or CCITT fax encoding, and some use odd fonts or colour profiles. pdf.js handles those with extra files that it downloads only when it needs them: WebAssembly image decoders, character maps, standard fonts and ICC colour profiles. If those files aren't at the URLs pdf.js expects, it quietly skips the images it can't decode. That's why the pages came out blank.

The fix was to serve those files from the site and tell pdf.js where to find them:

pdfjs.getDocument({
  url: fileUrl,
  wasmUrl: '/pdfjs/wasm/',
  cMapUrl: '/pdfjs/cmaps/',
  cMapPacked: true,
  standardFontDataUrl: '/pdfjs/standard_fonts/',
  iccUrl: '/pdfjs/iccs/',
});

On BECE Vault, a small Vite plugin copies those folders straight out of the installed pdfjs-dist package, so they always match its version. If you ever get blank pages in a pdf.js viewer, check this before anything else.

Picking up where you left off

The feature I wanted most was resume reading. The viewer remembers the last page you reached in each file and opens it there next time, with a short note saying so.

Doing that means saving something on the visitor's device, so I tied it to the site's cookie consent. If you've accepted, your reading position is saved in local storage and kept between visits. If you haven't, it lasts until you leave and then it's gone. It's a tiny bit of data, but saving anything without asking didn't sit right with me.

Looking back

The biggest surprise was how much a library can depend on without telling you. pdf.js seemed to work perfectly for a while before I found the files it had been quietly missing.

Most of the speed came from not doing work, rather than doing it cleverly. Skipping off-screen pages and throwing away distant ones made a bigger difference than any optimisation I tried.

And storing positions as fractions of a page, instead of pixels, fixed a whole set of zoom and rotation bugs at once.

You can try the viewer by opening any note on notes.arpanadhikari7.com.np.

Keep reading

All posts