Design a PDF viewer
A reusable PDF viewer component that opens a 2-page invoice or a 900-page manual with the same speed: page 1 on screen before the file finishes downloading, smooth scrolling and zoom, search, text selection, thumbnails, annotations, and forms. The hard parts are drawing pages yourself without running out of memory, loading only the bytes you need, and mapping between PDF coordinates and screen pixels so highlights stay put under zoom and rotation.
The question
Our product shows PDFs everywhere: contracts people sign, invoices, 500-page user manuals, scanned receipts. Today we drop them in an iframe and get a different viewer in every browser, and on some phones nothing at all. Design a PDF viewer component that every team can embed.
Walk me through how you'd draw the pages, how a big file opens quickly, how scrolling and zoom stay smooth on a long document, how search and text selection work, and how users add highlights and fill forms. Tell me what the component's API looks like and what could go wrong with security.
Real-life examples: Firefox's built-in viewer (PDF.js), Google Drive file preview, Dropbox preview, DocuSign signing view, Notion PDF embeds
Requirements: what to ask before drawing anything
Every browser can already show a PDF, so the first thing to find out is why that isn't enough here. Usually the answer is a mix of three things: the built-in viewer looks and behaves differently in Chrome, Firefox, and Safari; it can't be extended with our own toolbar, highlights, or signing fields; and on some phones it doesn't show inline at all. Once you know which of these matter, the rest of the questions decide how much of a PDF engine you must ship to the browser.
Ask from the outside in: who embeds the viewer, what kind of documents, how big, on which devices, and what users do with them besides reading. The table shows the answers this guide assumes and what would move if the answer changed.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who embeds it? | Many teams in one company: file previews, contract signing, help center manuals. | A small, stable component API with events and plugin slots matters as much as the rendering. |
| What documents? | Mostly born-digital PDFs (exported from Word or a web app), some scanned paper. | Born-digital files have real text for search. Scans are images and need OCR (optical character recognition, turning pixels into text) before search works. |
| How big? | Typical: 1 to 30 pages, under 5 MB. Long tail: 1,000 pages and 200 MB. | The long tail forces range loading and page virtualization. A design for 5 MB only would break at 200 MB. |
| Which devices? | Desktop and phones about equally. Older Android phones with 3 to 4 GB of RAM. | Memory is the binding limit on phones. Canvas sizes must be capped. |
| Read only, or edit? | Read, search, select, highlight, comment, fill forms. No page editing. | Annotations need a coordinate model and a storage decision. Page editing (reorder, delete) is out. |
| Where do files live? | Our storage, served through a CDN with signed URLs. | We control headers, so range requests and CORS can be set up correctly. |
| Accessibility bar? | WCAG 2.2 AA for our own UI. Document accessibility depends on the file. | Keyboard and screen reader support for the viewer, and honest limits for untagged PDFs. |
Functional requirements
- Open a PDF from a URL (or bytes already in memory) and show page 1 fast, even for a large file.
- Scroll through all pages continuously, with a page counter and a page number input.
- Zoom: fit width, fit page, preset levels from 25% to 400%,
Ctrlplus wheel, and pinch on touch. Rotate pages by 90 degrees. - A sidebar with page thumbnails and the document outline (bookmarks).
- Select and copy text. Find text across the whole document with a match count and next and previous.
- Follow links inside the document and to the web.
- Add highlights and comments, stored per user, shown to collaborators.
- Fill AcroForm fields (text boxes, checkboxes) and submit or save the values.
- Print and download, if the host allows them.
- Deep links: a URL that opens page 47 of a manual, or a search term.
Non-functional requirements
- Page 1 visible in under 1.5 seconds on a good connection for a 50 MB linearized file, without downloading all 50 MB first.
- Scrolling stays at 60 frames per second: no main-thread task over 50 ms while scrolling, so parsing happens off the main thread and painting runs in small slices.
- Canvas memory stays under a budget, about 150 MB on phones, at any zoom level and any document length.
- The viewer's own code costs little on pages that never open a PDF: it loads lazily, on first use.
- Untrusted files are safe to open: a PDF must not be able to run code in our origin.
- Keyboard and screen reader users can open, navigate, search, and read the text the file provides.
Out of scope, and why
Editing page content (moving text, reordering pages), creating PDFs, and digital signature cryptography are separate products. Signing a contract by placing a drawn signature image is close to an annotation, so the design should leave room for it, but verifying certificate-based signatures is a server job. Real-time co-editing of annotations is a follow-up: we store them on the server and refresh, which covers most review workflows.
Check your understanding · Exercise 1
Which answers to scope questions would change this design the most? Select all that apply.
What makes a PDF viewer hard
A PDF is not a web page. A web page is a stream of text that the browser lays out to fit your screen. A PDF is a set of fixed pages, each one a little program of drawing instructions: move here, set this font, draw these glyphs at these exact coordinates, fill this path, paint this image. Nothing reflows, and five properties of that format drive every decision in this guide.
- Pages are drawings, not markup. To show a page you must run its drawing instructions with a PDF engine and paint the result into pixels, usually a
canvas. The browser's layout engine can't help, and there is no DOM text to select or search unless you build one. - Files are big and random-access. A PDF has a cross-reference table (the
xref) that says at which byte each object starts. That table normally sits at the end of the file. A viewer that reads bytes in order can't draw page 1 until it has the end of the file, unless it can ask for specific byte ranges. - Pixels are expensive. A canvas costs 4 bytes per pixel. One A4 page at 200% zoom on a phone with a device pixel ratio of 3 (3 screen pixels per CSS pixel in each direction) is about 32 million pixels, or 128 MB. Ten of those and the tab crashes. Memory, not CPU, is the limit you hit first.
- Coordinates are not screen coordinates. PDF positions are in points (1/72 inch) with the origin at the bottom-left of the page and y pointing up. The screen has its origin at the top-left, y pointing down, scaled by zoom and device pixel ratio, maybe rotated. Every highlight, link, form field, and search match has to cross between the two.
- Files are untrusted programs. A PDF can contain JavaScript, embedded fonts with their own instructions, forms that submit data, and links to anywhere. Parsing it is parsing attacker-controlled input with a large, complex parser.
The wireframe below names the parts of the viewer we are building, so the later chapters have words to point at: the toolbar, the sidebar with thumbnails and outline, the scrolling page column, and the find bar.
Think first
Why not convert each PDF page to HTML and let the browser lay it out? Some tools do exactly that.
Converting to HTML means positioning every glyph run absolutely with the exact font, because PDFs don't reflow. You end up with thousands of absolutely positioned elements per page, embedded fonts converted to web fonts, and vector paths turned into SVG. It works for simple pages and gets heavy and inexact for complex ones: a page of a CAD drawing can have 100,000 paths.
A canvas draws all of that into one bitmap with a fixed memory cost that depends on pixel size, not on how complex the page is. That's why viewers draw pages to a canvas and add a thin, invisible text layer on top just for selection and search. You'll see that split again in the next two chapters.
Who draws the pages: built-in viewer, PDF.js, an SDK, or the server
The naive design is one line: iframe src="/files/contract.pdf". The browser picks its own viewer and shows the file. It costs zero bytes of JavaScript and works on most desktops. Then the bug reports arrive.
Chrome shows its viewer (built on the PDFium engine), Firefox shows PDF.js, and Safari shows Apple's own viewer: three toolbars, three zoom behaviors, three keyboard maps. On phones it's uneven. An iframe may show only the first page, a download button, or nothing, depending on the browser and version. You can check with navigator.pdfViewerEnabled, which is false when the browser can't show PDFs inline.
Worse, you can't add a highlight tool, a signing field, or analytics. The viewer is the browser's, not yours, and your page can't reach into it.
So the real question is which engine turns PDF bytes into pixels, and where it runs. There are four realistic answers.
Decision Which engine renders the pages?
What we pick: Use PDF.js in the client as the main engine, with two helpers. A server pipeline makes a low-resolution image of page 1 and the thumbnails at upload time (so the preview shows something even before PDF.js loads) and runs OCR on scanned files. Keep the built-in viewer as a link-out fallback for the rare case where our viewer fails to load. Pick a commercial SDK instead only if the product needs real PDF editing, redaction, or certified digital signatures: building those on PDF.js is years of work, and the license is cheaper than that.
The figure compares the four options on what matters for this product (PDF.js sizes measured for pdfjs-dist 6.4, minified and gzipped). No option wins every row: the decision is which costs you can live with.
Check your understanding · Exercise 2
A legal-tech startup needs users to black out (redact) text so it is truly removed from the file, then sign with a certificate. Which choices make sense? Select all that apply.
How PDF.js turns bytes into a page you can read
Before designing around PDF.js, know what it does with a file, because every later decision (virtualization, zoom, search) works with these pieces. PDF.js splits the work across two threads and builds each page from three stacked layers.
Two threads: the worker parses, the main thread paints
Parsing a PDF is heavy: decompressing streams, decoding fonts and images, reading the cross-reference table, turning each page's content stream into drawing commands. PDF.js does all of that in a web worker, a background thread with no access to the DOM. The worker sends the main thread an operator list for each page: a compact list of drawing calls like setFont, showText, fill, paintImageXObject, with their arguments.
Fonts are converted to OpenType and loaded with the CSS Font Loading API; images arrive already decoded. The main thread then replays the operator list onto a canvas 2D context. Parsing never blocks scrolling. Painting does, but it's the cheaper part, and PDF.js runs it in short time slices so a complex page doesn't freeze the tab.
cancel(), so a fast scroll cancels pages it flew past instead of queueing them.import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
GlobalWorkerOptions.workerSrc = "/static/pdf.worker.min.mjs";
const pdf = await getDocument({ url: "/files/manual.pdf" }).promise;
const page = await pdf.getPage(1);
const zoom = 1.5;
const viewport = page.getViewport({ scale: zoom * (96 / 72) });
const dpr = window.devicePixelRatio || 1;
const canvas = document.querySelector("canvas")!;
canvas.width = Math.floor(viewport.width * dpr); // device pixels
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${viewport.width}px`; // CSS pixels
canvas.style.height = `${viewport.height}px`;
const task = page.render({
canvas,
viewport,
transform: dpr === 1 ? undefined : [dpr, 0, 0, dpr, 0, 0],
});
await task.promise; // task.cancel() if the page scrolls away first
Three details in that code matter later. First, 96 / 72: a PDF point is 1/72 inch and a CSS pixel is 1/96 inch, so 100% zoom means 1.333 CSS pixels per point. An A4 page (595 by 842 points) is therefore 794 by 1123 CSS pixels at 100%.
Second, the canvas has two sizes: its backing store in device pixels (sharp on a Retina screen) and its CSS size, and memory follows the first. Third, render returns a task you can cancel, so a fast scroll past 40 pages can cancel 38 renders instead of queueing them.
Three layers per page
A canvas is just pixels: you can't select text in it, a screen reader sees nothing, and links don't click. So each page is a stack of three same-sized boxes, positioned exactly on top of each other.
- Canvas layer: the painted page. Everything you see comes from here.
- Text layer: one absolutely positioned
spanper run of text, frompage.getTextContent(), which returns each string with its transform matrix and width. The text is transparent, sized with the same font metrics, and stretched with a CSSscaleXso each span covers exactly the glyphs painted underneath. You select and copy real DOM text; the browser's selection highlight shows through. PDF.js builds it with itsTextLayerclass. - Annotation layer: real DOM elements for interactive parts of the page:
aelements for links,input,select, andtextareaelements for form fields, popups for comments already in the file. PDF.js builds it with itsAnnotationLayerclass frompage.getAnnotations().
Why not draw the text into the canvas and also keep it hidden in the DOM, like an accessible alternative? Because the spans must sit exactly over the glyphs: that's what makes a drag-select follow your mouse across the painted words and what lets find draw highlights in the right place. The text layer is not a copy of the text, it's a transparent map of where the text is.
Check your understanding · Exercise 3
A user drag-selects a sentence and copies it, then clicks a link on the same page. Which layers handle these? Select all that apply.
Loading big files: page 1 before the last byte
The naive loader fetches the whole file, then parses it. For a 2 MB invoice that's fine. For a 120 MB scanned manual on a 20 Mbps phone connection, that's 48 seconds of spinner before anything appears, and the user wanted page 3. We want page 1 on screen after a few hundred kilobytes, whatever the file size.
Where the bytes for page 1 live
A PDF is a pile of numbered objects (pages, fonts, images, content streams) plus a cross-reference table (xref) that gives the byte offset of every object. In a normal PDF the xref and a startxref pointer sit at the very end of the file, and page 1's objects can be anywhere. So the reader must look at the end first, then jump around. That's a perfect fit for HTTP range requests: ask the server for just the bytes you need.
GET /files/manual.pdf HTTP/1.1
Range: bytes=125763584-125829119
HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Range: bytes 125763584-125829119/125829120
Content-Length: 65536
Content-Type: application/pdf
ETag: "a91f3c"
A linearized PDF (Acrobat calls it "Fast Web View") is rewritten so page 1 needs no jumping. The file starts with a small linearization dictionary (within the first 1,024 bytes) that says the file is linearized and how long it is. Next come a cross-reference section for page 1, the document catalog, hint tables that say where every other page's objects are, and then page 1's own objects. A viewer that reads the first chunks can draw page 1 right away.
You can linearize at upload time with a tool like qpdf --linearize, which is cheap insurance for big files.
Decision How should the viewer fetch the file?
What we pick: Range requests with streaming as the fallback, which is PDF.js's default behavior when you pass a url. Our job is to make the server side correct so ranges actually get used, and to linearize big files at upload. Then tune background fetching by file size and network: let it run for files under about 20 MB, and turn it off (disableAutoFetch: true together with disableStream: true, as the PDF.js docs require) for huge files or when navigator.connection.saveData is on (Chromium only). That way a user reading 3 pages of a 200 MB file doesn't pay for 200 MB.
What the server must send, or ranges silently turn off
PDF.js decides from the first response's headers whether to use ranges. In its source (validateRangeRequestCapabilities), ranges are used only when all of these hold. If any fails, it quietly falls back to streaming the whole file, and nobody notices until someone opens a 200 MB file on a phone.
Content-Lengthis present. Without a total length there is no way to compute chunk positions.- The file is bigger than two chunks (by default 2 x 64 KB = 128 KB). Smaller files are just downloaded.
Accept-Ranges: bytesis present.- No compression:
Content-Encodingis absent oridentity. A gzipped response has byte offsets that don't match the file, so ranges can't work. Don't let a CDN gzip PDFs; they're mostly compressed inside anyway. - For a file on another origin, CORS allows it:
Access-Control-Allow-Originfor our origin,Access-Control-Allow-Headers: Rangeso a preflightedRangerequest is allowed, andAccess-Control-Expose-Headers: Accept-Ranges, Content-Range, Content-Encoding, because JavaScript can't read headers outside the CORS-safelisted set (which already includesContent-Length) unless they're exposed. The CORS lesson covers why.
Caching works with ranges too. The CDN stores the full object and answers range requests from it, and the browser's HTTP cache can store 206 responses. Version the URL (or rely on a strong ETag) so a replaced file never mixes chunks from two versions: PDF.js would read an xref from the old file and objects from the new one, which shows as corrupted pages. The http-caching and cdn-caching lessons cover the headers.
Check your understanding · Exercise 4
Your 80 MB manual takes 30 seconds to show page 1 in production, but 1 second locally. Which causes fit? Select all that apply.
Rendering only what you see, within a memory budget
The naive viewer creates a canvas for every page and renders them all. A 30-page invoice survives. A 600-page manual at 100% on a laptop with a device pixel ratio of 2 needs 600 canvases of 14 MB each: 8.6 GB. The tab dies long before that, often with no error, just a blank page or a crash screen, because memory is the budget here and rendering is how you spend it.
The fix is the same idea as list virtualization (the virtualization lesson and the virtualized-list guide cover the windowing math): keep a lightweight placeholder for every page so the scrollbar is right, and paint real canvases only for pages near the viewport. Three things make pages different from list rows: each row is a heavy bitmap, not a few DOM nodes; rendering one takes 50 to 500 ms, not microseconds; and its cost grows with the square of the zoom.
Canvas memory math
A canvas backing store costs width x height x 4 bytes, in device pixels. An A4 page is 794 by 1123 CSS pixels at 100%. Multiply each side by zoom and by devicePixelRatio.
| Zoom | devicePixelRatio | Canvas size (device px) | Pixels | Memory |
|---|---|---|---|---|
| 100% | 1 | 794 x 1123 | 0.9 million | 3.6 MB |
| 100% | 2 | 1588 x 2246 | 3.6 million | 14 MB |
| 100% | 3 | 2382 x 3369 | 8.0 million | 32 MB |
| 200% | 2 | 3176 x 4492 | 14.3 million | 57 MB |
| 200% | 3 | 4764 x 6738 | 32.1 million | 128 MB |
| 400% | 2 | 6352 x 8984 | 57.1 million | 228 MB |
Browsers also cap a single canvas, and none of them document the limit; these are measured values that change between versions. Desktop Chrome and Firefox allow up to 32,767 pixels per side, with a total area limit in the hundreds of millions of pixels. iOS Safari limits a canvas to about 16.7 million pixels (4,096 x 4,096). Exceed the limit and the canvas silently draws nothing.
That's why PDF.js's viewer has a maxCanvasPixels option: 2 to the 25th (about 33.5 million pixels) on desktop and 5,242,880 (5 megapixels) on iOS and Android. Recent versions also cap each side (maxCanvasDim, 32,767) and cap the area relative to the screen (capCanvasAreaFactor). Above the cap the viewer renders at a lower resolution and stretches the canvas with CSS, which looks slightly soft. It then adds a second, small detail canvas on top that paints only the part of the page near the viewport at full sharpness.
Decision How many pages get a real canvas, and when do we free them?
What we pick: Visible plus a buffer, under a byte budget rather than a page count: about 150 MB on phones and 400 MB on desktops. A page count is the wrong unit, because 10 pages at 50% zoom and 10 pages at 400% zoom differ by a factor of 64. Render one page at a time, most visible first, so a fast scroll never builds a queue of 30 renders; cancel any render whose page has left the buffer.
Desktop, 100% zoom, device pixel ratio 2. Scroll or zoom to see canvases appear and get evicted.
Placeholders need sizes before pages load
The scrollbar must be right from the start, or the page number under the thumb is wrong and jumping to page 400 lands somewhere else. That needs every page's size before rendering any of them. Getting a page's size from PDF.js means loading that page's dictionary, which with range loading can mean a request per page.
There are two practical answers. One: assume every page is the size of page 1 (true for most documents) and correct each placeholder when its page loads, keeping the reading position anchored when a correction happens above the viewport. Two: have the upload pipeline write a small manifest of all page sizes and rotations, which the viewer fetches alongside the file. We do both: the manifest when it exists, page 1's size when it doesn't.
rendered and stretched hold canvas memory. Eviction sets the canvas width and height to 0 so the memory is freed at once, not at the next garbage collection.Freeing a canvas has a trick. Removing it from the DOM doesn't free its memory until garbage collection runs, which may be seconds later; on iOS Safari, which also limits total canvas memory per page, slow collection is how you hit that limit. Set canvas.width = 0 and canvas.height = 0 before dropping it: that releases the backing store right away.
Text layers need virtualizing too: a dense page can have 3,000 spans. Drop the text and annotation layers of far-away pages and rebuild them from cached text content, which is cheap.
Check your understanding · Exercise 5
A user on an iPhone (devicePixelRatio 3) zooms to 200% on an A4 page. What should the viewer do? Select all that apply.
Zoom and rotate without losing your place
Zoom looks like one number, but it touches everything: every page's size, every canvas's resolution, the scroll position, the text and annotation layers, and the memory budget. The naive version sets scale and re-renders every visible page on each change.
A trackpad pinch fires 60 or more wheel events per second. Each one starts a 200 ms render that the next event cancels, so the user sees blank or flickering pages until they stop. And the page they were reading has drifted off screen, because the scroll offset stayed the same while everything above it got taller.
Decision What happens on screen while the user is zooming?
What we pick: CSS scale now, re-render after the gesture settles, swap when ready. PDF.js's own viewer works in a similar way (it waits briefly after a pinch before redrawing), and it's the same pattern as map and design tools (the figma guide uses it for its canvas). The memory budget must count the moment when both the stretched old canvas and the new sharp one exist.
Keeping the reading position anchored
When zoom changes, the point under the user's fingers (or under the mouse for Ctrl plus wheel, or the center of the viewport for the toolbar buttons) must stay under them. Everything above that point grows, so the scroll offset must grow with it. Record the anchor in document coordinates before the change, then set the scroll so the same document point lands at the same screen point after.
function zoomAround(scroller: HTMLElement, oldZoom: number, newZoom: number,
anchorX: number, anchorY: number) {
// anchorX/Y: the anchor in viewport pixels, relative to the scroller
const docX = (scroller.scrollLeft + anchorX) / oldZoom;
const docY = (scroller.scrollTop + anchorY) / oldZoom;
setZoom(newZoom); // resizes page boxes synchronously (CSS sizes only)
scroller.scrollLeft = docX * newZoom - anchorX;
scroller.scrollTop = docY * newZoom - anchorY;
}
scroller.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return; // trackpad pinch also arrives as ctrl+wheel
e.preventDefault(); // stop the browser zooming the whole page
const r = scroller.getBoundingClientRect();
const next = clamp(zoom * Math.exp(-e.deltaY / 300), 0.25, 4);
zoomAround(scroller, zoom, next, e.clientX - r.left, e.clientY - r.top);
scheduleSharpRender(); // debounced: runs about 200 ms after the last event
}, { passive: false });
Pass passive: false explicitly. Wheel listeners on window, document, or body are passive by default for scroll performance, and a passive listener can't call preventDefault, so if the listener ever moves up to the document the browser would zoom the whole page instead. On touch screens, set CSS touch-action: pan-x pan-y on the scroller so the browser handles one-finger panning but leaves two-finger pinch to our pointer event handler.
Fit modes and rotation
- Fit width: zoom = (container width minus padding) divided by the page's CSS width at 100%. It's the default on phones, where a 390-pixel screen shows an A4 page at about 47%.
- Fit page: the smaller of the width ratio and the height ratio, so a whole page is visible. Good for slides.
- Fit modes are modes, not numbers: store
fit-widthin state and recompute the zoom when the container resizes (aResizeObserver), the sidebar opens, or the phone rotates. - Rotation is a view setting in steps of 90 degrees, passed to
page.getViewport({ scale, rotation }). At 90 or 270 degrees width and height swap. It doesn't change the file; if users want rotation saved, store it with their view settings, or have the server rewrite the page's/Rotateentry.
Check your understanding · Exercise 6
Users report that after pinch-zooming on page 40, they end up on page 37. What could cause it? Select all that apply.
Text selection and find across the whole document
With a text layer in place, the browser's own Ctrl+F finds text in it. That's the naive version, and virtualization breaks it: the browser can only find text that's in the DOM, and only the 3 to 10 pages near the viewport have text layers. Searching a 600-page manual for "warranty" finds the 2 matches on screen and misses the 40 elsewhere. Find in a PDF viewer has to search the document, not the DOM.
Decision Where does find run?
What we pick: Client-side find as the default, because it works for any file from any source with no backend. Add the server index for the product's own library: it powers search across documents and gives instant first results for 500-page files, while the client still highlights within the open file. Intercept Ctrl+F and Cmd+F only while focus is inside the viewer, and let a second press open the browser's find, so we don't take a basic browser feature away.
Matching text that was never written for searching
PDF text is positioned glyph runs, not sentences. A word can be split into several items ("war" and "ranty" set with different spacing), a line-end hyphen can break it ("war-" then "ranty"), ligatures replace letter pairs (the single glyph "fi" for "fi"), and accents may be stored as separate combining marks. So each page's items are joined into one string, normalized, and a map is kept from each position in that string back to an item index and a character offset in it.
type PageText = { text: string; map: { item: number; char: number }[] };
function buildPageText(items: { str: string; hasEOL?: boolean }[]): PageText {
let text = "";
const map: PageText["map"] = [];
items.forEach((it, item) => {
const hyphenEOL = it.hasEOL && it.str.endsWith("-");
const s = hyphenEOL ? it.str.slice(0, -1) : it.str; // "war-" + EOL joins "ranty"
const norm = s.normalize("NFKD") // "fi" -> "fi", "é" -> "e" + mark
.replace(/\p{M}/gu, "").toLowerCase(); // drop accents, ignore case
for (let c = 0; c < norm.length; c++) {
text += norm[c];
map.push({ item, char: Math.min(c, s.length - 1) });
}
if (it.hasEOL && !hyphenEOL) { text += " "; map.push({ item, char: s.length }); }
});
return { text, map };
}
The mapping is approximate when normalization changes length (one ligature glyph becomes two letters). The hyphen rule also joins compounds like "well-known" that break at a line end, so real viewers track offsets and hyphens more carefully. The shape is right, though: search a clean string, then translate each match back to spans on the page.
Highlighting wraps the matched characters of the right spans in a mark-like element with a background, or draws boxes over them in a separate layer. Either way it happens only when the page's text layer exists, so highlights for page 300 are applied when page 300 renders.
- Search progressively: pages near the current page first, then outward, updating "3 of 41" as results arrive. Debounce typing by about 250 ms.
- "Next" from the last match on page 12 goes to the first match on page 13 or later, which may need a scroll, a render, and then the highlight. Mark the current match differently from the others.
- Announce the count in a polite live region ("41 matches"), not on every keystroke. The screen-readers lesson covers live regions.
- Cache extracted text per document (in memory, or IndexedDB for files reopened often). The second search is instant.
- Scanned PDFs have no text to find, as the text layer warning in the PDF.js chapter explains. Detect it (a page with a big image and no text items), tell the user "This document has no searchable text", and run OCR on the server. A tool like OCRmyPDF adds an invisible text layer to the PDF, which PDF.js then reads like any other text.
Check your understanding · Exercise 8
Searching "financial" finds nothing in a report that clearly says "financial" on page 4. What are the possible causes? Select all that apply.
Annotations and forms: anchoring to the page, not the screen
A reviewer highlights a clause at 150% zoom on a laptop. The naive version stores the highlight as screen pixels: { page: 12, x: 310, y: 482, w: 220, h: 18 }. A colleague opens it on a phone at 47% with the page rotated, and the highlight lands on the wrong line or off the page. Screen pixels depend on zoom, device pixel ratio, rotation, and layout, but the page itself doesn't change, so store positions in the page's own coordinates.
PDF user space and the viewport transform
PDF user space measures in points (1/72 inch) from the page's bottom-left corner, with y pointing up. An A4 page runs from (0, 0) to (595, 842). The screen measures in CSS pixels from the top-left, with y pointing down. PDF.js's PageViewport holds the transform between the two for a given scale and rotation.
The viewport has helpers both ways: viewport.convertToPdfPoint(x, y) when the user drags, and viewport.convertToViewportPoint(x, y) on two opposite corners when drawing a stored highlight. (Version 6.1 removed the old convertToViewportRectangle helper, which did the same for a whole rectangle.) At 100% and no rotation, a point at y = 700 in PDF space is 142 points from the top, which is 189 CSS pixels down.
PDF rectangle [72, 690, 222, 704] in points. At 100% and 0 degrees it is left 96, top 184, 200 x 19 in CSS pixels on a 794 by 1123 page.
A text highlight isn't one rectangle: a selection across three lines is three rectangles (in PDF's own format, a list of quadrilaterals). Store the list, plus the selected text itself so the comment list can show "liability is limited to" without rendering the page. Build the rectangles from the selection's Range.getClientRects() on the text layer, converting each to PDF space.
Decision Where do highlights and comments live?
What we pick: Server-side annotations as the source of truth, with an "Export with comments" action that writes them into a copy of the PDF on the server. That matches how review tools work: comments are collaborative data with authors and states, not part of the document. If the file is replaced by a new version, keep the old annotations tied to the old version and offer to carry them over where the text still matches.
Filling forms
AcroForm fields (the standard PDF form type) come from page.getAnnotations() as widget annotations with a field name, type, rectangle, and current value. The annotation layer renders them as real input, select, and textarea elements, positioned and sized from their PDF rectangles, so typing, tab order, autofill, and screen readers all work natively. PDF.js keeps the values in pdf.annotationStorage.
To submit, read the values by field name and post them as JSON; to produce a filled PDF, call pdf.saveDocument() for the bytes, or fill the original on the server. The older XFA form format is mostly unsupported outside Adobe's own readers. PDF.js renders some of it with its enableXfa option, but plan to tell users when a form can't be filled.
Check your understanding · Exercise 9
Which ways of storing a highlight survive zoom, rotation, and a different device? Select all that apply.
Architecture and data: who owns what
With the hard parts decided, put names on the pieces. The main rule: PDF.js is wrapped by one service and nothing else talks to it. That keeps its async, cancellable, memory-heavy objects in one place, lets us upgrade it (which security requires, as the security chapter shows), and gives the UI plain data to render.
| Part | Job | Why it's separate |
|---|---|---|
PdfViewer shell | Takes props, owns the viewer store, lays out toolbar, sidebar, page list. | The public component. Everything else is internal and replaceable. |
| Viewer store | Current page, zoom and fit mode, rotation, sidebar tab, find query. Synced to the URL hash. | One source of truth for view state, so toolbar, sidebar, and pages never disagree. |
PageList virtualizer | Placeholder boxes from page sizes, the visible set from scroll, anchoring on zoom. | Pure layout math, testable without PDF.js. |
PageView | One page: canvas, text layer, annotation layer, our highlight layer. | Mounts and unmounts as pages enter and leave the window. |
| Render scheduler | Priority queue of page renders, one at a time, cancellation, canvas memory budget with eviction. | Rendering is the scarce resource. One owner decides what gets painted next. |
| Document service | Wraps PDF.js: loading, page proxies, text content cache, outline, page labels, cleanup. | The only code that imports PDF.js. Lazy-loaded with its worker. |
| PDF.js worker | Parses, decodes, produces operator lists and text content. | Keeps heavy parsing off the main thread. |
| Find controller | Extracts and normalizes text, matches, tracks the current result. | Has its own lifecycle (runs across all pages, not just visible ones). |
| Annotation store and API client | Loads, creates, and syncs highlights and comments in PDF coordinates. | Server data with optimistic updates, unrelated to rendering. |
The data flow for opening and reading a document goes in one direction: the host passes src; the document service asks the CDN for byte ranges and hands them to the worker; the worker answers with page sizes, operator lists, and text; the scheduler paints canvases for the pages the virtualizer says are visible; user actions change the store, which changes the visible set or zoom, which schedules new renders. Annotation writes go the other way, from a page's highlight layer to the store to the API.
The client-side data model
type DocInfo = { // document service, in memory
id: string; version: string; // the version keys caches and annotations
numPages: number;
pageSizes: { w: number; h: number; rotate: 0 | 90 | 180 | 270 }[];
outline: OutlineNode[] | null;
pageLabels: string[] | null;
};
type ViewState = { // viewer store, mirrored in the URL hash
page: number;
zoom: number | "fit-width" | "fit-page";
rotation: 0 | 90 | 180 | 270;
sidebar: "thumbnails" | "outline" | null;
query: string;
};
type RenderedPage = { // render scheduler, LRU by lastSeen
page: number; scale: number; bytes: number; lastSeen: number;
canvas: HTMLCanvasElement;
};
type Annotation = { // server data, cached and optimistic
id: string; docVersion: string; page: number;
kind: "highlight" | "comment";
rects: [number, number, number, number][]; // PDF user space
quote?: string; body?: string; authorId: string; createdAt: string;
};
Notice what isn't in global state: canvases and PDF.js page proxies. They are big, mutable, and tied to the worker, so they live inside the scheduler and the service, keyed by page number, and the UI only sees "page 12 is rendered at scale 2". Extracted text lives in the service's cache, optionally saved to IndexedDB by document version so reopening a big manual skips extraction. Annotations are server state with the usual cache, optimistic insert, and rollback on error.
Check your understanding · Exercise 10
Why put PDF.js behind a single document service? Select all that apply.
The component API teams will use
Many teams embed this viewer: a file preview wants a toolbar and nothing else, contract signing wants form fields and a "Sign" button, the help center wants deep links and search. The API should make the simple case one line and the complex cases possible without forking. The component-api-design lesson covers the general rules; here's how they land for a PDF viewer, in the same style as our video player.
type PdfViewerProps = {
src: string | Uint8Array; // URL (range-loaded) or bytes
onRequestUrl?: () => Promise<string>; // fresh signed URL after a 403
password?: () => Promise<string>; // asked only if the file is encrypted
page?: number; defaultPage?: number; // controlled or uncontrolled
zoom?: Zoom; defaultZoom?: Zoom; // number or "fit-width" | "fit-page"
rotation?: 0 | 90 | 180 | 270;
onPageChange?: (page: number) => void;
onZoomChange?: (zoom: Zoom) => void;
onLoad?: (info: { numPages: number; title?: string }) => void;
onError?: (e: ViewerError) => void; // "network" | "password" | "invalid" | "unsupported"
onTextSelect?: (s: { page: number; text: string; rects: Rect[] }) => void;
allowDownload?: boolean; allowPrint?: boolean;
plugins?: ViewerPlugin[]; // annotations, signing, analytics
toolbar?: (slots: ToolbarSlots) => React.ReactNode;
};
- Controlled or uncontrolled: pass
defaultPageand let the viewer own it, or passpageandonPageChangewhen the host must drive it (a contract checklist that jumps to each field). Never require the controlled form for simple use. - An imperative handle through a ref for actions that aren't state:
scrollToPage(n),find(query),print(),getSelection(),scrollToRect(page, rect). - Plugins get a small, stable context instead of PDF.js objects: the viewer store, coordinate helpers (
toPdfRect,toCssRect), a layer they can draw into for each page, and toolbar slots. The annotation tools and the signing fields are plugins, which proves the plugin API is good enough. - Errors are typed, so hosts can show a password prompt, a "file is damaged" state, or a retry. A failure to load must never be a blank box.
- Lazy by default: the package entry is a tiny shell that
import()s the viewer and PDF.js on mount, so a page that might show a PDF doesn't pay 500 KB until it does. Preload the worker on hover of a file link.
refresh URL state is what keeps a long reading session alive when a signed URL expires.Note what's not in the API: PDF.js's own objects. If onLoad handed out the PDFDocumentProxy, every team would depend on PDF.js's API version, and the next PDF.js upgrade would break them. The plugin context is our contract; PDF.js is our implementation detail.
Check your understanding · Exercise 11
The signing team wants to place "Sign here" boxes on specific spots and jump between them. What should the API offer? Select all that apply.
Printing, downloading, and opening untrusted files safely
Printing a canvas-rendered document is less obvious than it sounds. window.print() prints the page's DOM, and our DOM holds a dozen canvases sized for the screen plus a thousand empty placeholders. Two approaches work.
The first is what PDF.js's viewer does. It renders every page into a canvas at a print resolution (150 DPI by default), turns each into an image in a hidden print container, then calls print(). An A4 page at 150 DPI is 1240 by 1754 pixels, about 8.7 MB as a bitmap, so a 300-page print job must render page by page into compressed images and show progress, or it runs out of memory.
The second hands the browser the original file in a hidden iframe and calls print on it. That keeps vector text sharp where the browser has a built-in viewer, but behaves differently per browser. Use the first as the default, and raise the resolution to 300 DPI for documents where print quality matters, like forms and contracts.
Hosts will ask for allowDownload: false. Hide the button, but be honest in the design review: it's a convenience setting, not protection. To show the document, the browser already received the bytes (they're visible in the network panel), and anyone can screenshot or print to PDF.
If a document must not leave, don't send the PDF at all. Serve server-rendered page images with a visible watermark naming the viewer, and accept the loss of selection and sharpness. That's a product decision, so name it as one.
A PDF is untrusted input to a big parser
PDFs can carry JavaScript (for form calculations and validation), launch actions, links with any URL scheme, form submit actions, and embedded fonts with their own programs. The PDF.js display library that we call (getDocument, render) never runs a document's JavaScript.
PDF.js's viewer components can run a limited, Acrobat-like scripting API when their enableScripting option is on (it is on by default in the full viewer app). In pdfjs-dist that code runs in a separate sandbox: the QuickJS engine compiled to WebAssembly. For our viewer, leave scripting off unless a specific form needs calculated fields.
The real risk is bugs in the parser itself. In May 2024, CVE-2024-4367 showed that a crafted font in a PDF could run arbitrary JavaScript in the page hosting PDF.js. PDF.js compiled glyph outlines into JavaScript functions for speed, and a missing type check on the font matrix let the file inject code into that function body. It was fixed in PDF.js 4.2.67 (Firefox 126), which stopped compiling glyphs into code.
The fix was easy; finding every copy was not, because many apps bundled an old pdfjs-dist through wrapper libraries and never upgraded. The workaround for old versions was setting isEvalSupported: false. PDF.js 5.7 (April 2026) removed the last code that used eval and the option with it, so current versions have neither.
postMessage protocol crosses the line, and the host validates every message.- Keep PDF.js current: pin it, watch its releases and security advisories, and treat an upgrade as routine maintenance. The document service wrapper keeps upgrades cheap.
- Isolate the viewer: for user-uploaded files, run the viewer in an
iframeon a separate origin (like ausercontentdomain), talking to the host page withpostMessage. A parser bug then runs in an origin with no cookies or user data. - Content Security Policy: no
unsafe-evalinscript-src, andworker-srclimited to our static origin. That blocks the code-from-strings class of bug entirely. PDF.js uses WebAssembly for some image decoders (and for the scripting sandbox), which needswasm-unsafe-eval: that allows compiling WebAssembly but not JavaScripteval. Without it, PDF.js falls back to slower JavaScript decoders. - Links: allow only
http,https, andmailtotargets (PDF.js already dropsjavascript:URLs), open external links withrel="noopener noreferrer", and consider a "You're leaving" prompt for links in untrusted files. - Forms: ignore a file's own submit actions. Collect field values and send them through our API.
Check your understanding · Exercise 12
Your app uses a React PDF wrapper that bundles PDF.js 3.11 and shows user-uploaded files on your main domain. Which steps reduce the risk? Select all that apply.
Accessibility and performance, honestly
Start with the honest limit: PDFs don't reflow. A web page at 200% zoom rewraps its text to fit the screen; a PDF page at 200% is just a bigger page, and a reader with low vision scrolls sideways on every line. WCAG's reflow criterion (1.4.10) asks content to fit a 320-CSS-pixel-wide viewport (400% zoom on a 1280-pixel window) without sideways scrolling, except where two-dimensional layout is essential. A fixed page of running text can't do that.
What we can do is make our own UI fully accessible, expose all the structure the file provides, and offer a way out. That way out is a "Reading view" that shows the extracted text as plain, reflowing HTML, page by page, with headings where the file is tagged.
- Tagged PDFs contain a structure tree (headings, paragraphs, lists, tables, figure alt text) and a logical reading order. PDF.js reads it with
page.getStructTree()and its viewer maps it onto the text layer, so screen readers can hear headings and alt text, as far as the file's tags are correct. Untagged PDFs give only text runs in content-stream order, which for two-column layouts can interleave columns. Tell users when a file is untagged; fixing it is the author's job. - The text layer is the accessible content. Each page region gets
role="region"with a label like "Page 4 of 612", so screen reader users can jump by landmark. Pages without a rendered text layer must still announce their existence, and get their text layer built when a screen reader moves into them, not only when they scroll into view. - Keyboard:
Page DownandPage Upmove by a page,HomeandEndto first and last,Ctrlplus+and-zoom (intercepted only while focus is in the viewer),Ctrl+Fopens our find bar. Toolbar buttons are realbuttonelements with labels; the thumbnail list and outline use arrow keys with a roving tabindex, as the keyboard-navigation lesson describes. Focus moves to the page heading after a jump, so screen reader users hear where they landed. - Reduced motion: smooth scrolling to search results is nice, but use instant jumps when
prefers-reduced-motion: reduceis set.
Performance budget and what to measure
The viewer's main metrics are product metrics, not just Core Web Vitals: time to first page painted, time to first text layer, and scroll smoothness. Record performance.mark at file request, first byte, document ready, and page 1 painted. Send them with the file size, page count, whether ranges were used, and device memory (navigator.deviceMemory, where supported). The "ranges used" field is what catches a CDN that quietly started compressing PDFs.
Watch long tasks during scroll with the Long Animation Frames API where available; a page render that paints a huge image in one chunk shows up there. Keep the PDF.js chunks out of the host page's critical path so its Largest Contentful Paint isn't hurt by a viewer that hasn't opened yet. The runtime-performance lesson covers the profiling side.
Check your understanding · Exercise 13
A blind user says the viewer reads a two-column research paper as nonsense. What could you do? Select all that apply.
Running it in the interview
Forty-five minutes is not enough for every chapter, so plan the time around the decisions that show judgment. Draw the wireframe and the layered page early: they give you vocabulary for everything else.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Scope: file sizes, scans, devices, annotations, who embeds it. | "If files can be 200 MB, range loading and memory caps drive the design." |
| 5 to 10 | Engine choice: built-in vs PDF.js vs SDK vs server images. | "PDF.js in a worker, server helpers for thumbnails and OCR, SDK only if we need redaction or signatures." |
| 10 to 20 | Draw the page stack and the architecture. Explain worker vs main thread. | "Canvas for pixels, text layer for selection and find, annotation layer for links and forms." |
| 20 to 32 | Deep dives: loading with ranges, then virtualization with the memory math. | "Twice the zoom is four times the memory, so the budget is in bytes, not pages." |
| 32 to 40 | Zoom, find, and annotations in PDF coordinates. The component API. | "Highlights are stored in PDF points; the viewport converts them every time we draw." |
| 40 to 45 | Security, accessibility, metrics. | "Disabling download isn't protection. PDFs don't reflow, so we offer a reading view." |
Expect follow-ups that test whether you understand the mechanisms, not the names. Short, strong answers:
- "The PDF has 10,000 pages." Placeholder sizes from a server manifest; page-size lookups only for visible pages; thumbnails from the server; find via the server index; the virtualizer is unchanged because it never had per-page DOM cost.
- "Users zoom to 800% on a map PDF." Per-canvas cap plus a viewport-sized detail canvas; tile the detail canvas if needed, like a map; the memory budget stays the same.
- "Two people annotate at once." Annotations are server rows, so concurrent inserts don't conflict; push new ones over a socket or poll; edits to the same comment use last-write-wins with a version check.
- "Can you render in the worker too?" Yes, with
OffscreenCanvastransferred to the worker, which keeps painting off the main thread. Name the costs: transfer and compositing, browser support details, and more complex cancellation. - "How would you test it?" A corpus of tricky real files (scans, huge, encrypted, broken xref, rotated, forms) rendered in CI and compared to reference images, plus unit tests for coordinate math and the virtualizer.
Check your understanding · Exercise 14
With 10 minutes left and nothing said about memory, which topics are most worth covering? Select all that apply.
Summary
- PDF pages are drawings, not markup. Render each page to a canvas with PDF.js, parse in a web worker, and stack a transparent text layer (selection, find, screen readers) and an annotation layer (links, forms) on top.
- Choose the engine on purpose: the built-in viewer has no API and no consistency, PDF.js gives control for about 500 KB gzipped, a commercial SDK buys redaction and signatures for a license fee, and server images trade sharpness for a tiny client.
- Big files load by HTTP range requests. They switch on only with
Content-Length,Accept-Ranges: bytes, noContent-Encoding, and CORS that exposes the headers. Linearize at upload so page 1 needs no jumping. - Canvas memory is width x height x 4 bytes in device pixels, so twice the zoom is four times the memory. Virtualize pages under a byte budget, cap each canvas, and add a detail canvas at high zoom.
- Zoom with CSS first and re-render sharply after the gesture settles, swapping canvases only when the new render is done. Anchor the point under the fingers so the reader never loses their place.
- Find must search extracted text from every page, normalized for ligatures, accents, and hyphens, not the DOM of the few rendered pages. Scans need server OCR first.
- Store highlights, comments, and signing boxes in PDF user space (points, origin bottom-left) and convert with the viewport every time you draw. Keep collaborative annotations on the server.
- Disabling download isn't protection, PDFs don't reflow, and a PDF is untrusted input to a large parser: keep PDF.js updated (CVE-2024-4367), isolate it on another origin, and block eval with CSP.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- PDF.js: Getting started What the library and viewer are, and how the pieces are packaged.
- PDF.js examples Minimal code to load a document and render a page to a canvas.
- PDF.js API reference getDocument options such as disableRange, disableStream, and disableAutoFetch, and the render parameters.
- PDF.js FAQ How PDF.js uses range requests and what cross-origin loading needs.
- MDN: HTTP range requests Accept-Ranges, Range, 206 Partial Content, and Content-Range.
- MDN: CORS-safelisted response header Which response headers JavaScript can read without Access-Control-Expose-Headers.
- MDN: Navigator.pdfViewerEnabled How to detect whether the browser can show PDFs inline.
- RFC 8118: The application/pdf Media Type Summarizes the PDF fragment identifiers (page, nameddest, zoom, search) used for deep links.
- PDF Association: ISO 32000-2 Annex F, Linearized PDF The order of the parts in a linearized (Fast Web View) file.
- qpdf: Linearization How qpdf, the tool suggested here, creates and checks linearized files.
- MDN: The canvas element Maximum canvas size and what happens when you exceed it.
- GitHub advisory for CVE-2024-4367 Affected PDF.js versions, the fix, and the workaround.
- Codean Labs: CVE-2024-4367 write-up How a font matrix became JavaScript execution, step by step.
- Understanding WCAG 2.2: Reflow Why fixed layouts struggle with 400% zoom.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.