cf.completefrontend
My progress
SYSTEM DESIGN · #44

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.

QuestionAnswer we assumeWhat 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%, Ctrl plus 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.

Wireframe of the PDF viewer showing manual.pdf. A toolbar runs across the top: sidebar toggle, page input 47 of 612, zoom out, 125%, zoom in, fit, rotate, find, and print. On the left a sidebar shows thumbnails for pages 45 to 48, with 47 highlighted. On the right is the scrolling page column: the bottom of page 46, then page 47 with lines of text and one highlighted phrase. A find bar at the top right reads warranty, 3 of 41. Notes name the find bar and the virtualized pages.manual.pdfside47/612-125%+fitrotfindprintthumbnails45464748page 46page 47warranty 3/41find barvirtualized pages612 placeholders
Every part maps to a chapter: the toolbar drives zoom and navigation, the sidebar is a second virtualized list, and the page column holds placeholders for all 612 pages but real canvases for only a few.

Think first

Why not convert each PDF page to HTML and let the browser lay it out? Some tools do exactly that.

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?

Browser's built-in viewer (iframe, embed, or object)

Point an iframe, embed, or object at the PDF URL and let the browser render it.

Pass open parameters in the fragment, like #page=3, where the browser supports them.

Pros

  • Zero bundle cost. Native speed and memory handling.
  • Printing, find, and accessibility come from the browser.

Cons

  • A different UI in every browser, and no inline viewer on many mobile browsers.
  • No API: no page change events, no custom tools, no annotations, no analytics.
  • Cross-origin files may be downloaded instead of shown, depending on headers like Content-Disposition.

PDF.js in the page (open source, Apache 2.0)

Mozilla's PDF engine written in JavaScript. A worker parses the file; the main thread draws each page into a canvas.

You build your own toolbar and UI, or reuse the viewer components that ship with it.

Pros

  • Same rendering and UI in every modern browser, including phones.
  • Full control: events, custom tools, text layer for search and selection, annotation and form layers.
  • Widely used: it is Firefox's own viewer, so it sees millions of real-world files.

Cons

  • Bundle cost: about 130 KB gzipped for the main library and about 370 KB gzipped for the worker (pdfjs-dist 6.4).
  • You own memory, virtualization, and zoom quality. Rendering is slower than native for very complex pages.

Commercial SDK (Nutrient, formerly PSPDFKit; Apryse, formerly PDFTron)

A full PDF engine compiled to WebAssembly, plus a ready-made viewer UI with annotation, form, redaction, and signing tools.

Pros

  • Most features out of the box: real editing, redaction, digital signatures, Office file conversion.
  • Vendor support and fidelity tuned for business documents.

Cons

  • Paid license, priced per deal, often per domain or per user. A procurement decision, not just a technical one.
  • Several megabytes of WebAssembly to download and compile before page 1.
  • Their UI and update schedule, not yours.

Server-rendered page images

A server renders each page to a WebP or PNG at a few widths and stores them on the CDN. The client shows them as plain img tags, lazily.

A separate text file with word positions can power selection and search.

Pros

  • Tiny client. Works on the oldest phone. The same pipeline can preview Word and PowerPoint files too.
  • The original file never needs to reach the browser.

Cons

  • Blurry at high zoom unless you render many sizes. Every zoom level is more storage and bytes.
  • A conversion step before first view, plus servers to run. Links and forms need custom work.

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.

Comparison of four ways to render PDFs: the browser's built-in viewer, PDF.js, a commercial SDK, and server-rendered images. JavaScript cost: 0 KB, about 500 KB gzipped, several megabytes (bad), about 20 KB. Our own tools: no (bad), yes, yes, build them. Sharp at 400% zoom: sharp, sharp, sharp, blurry (bad). License: free, Apache, paid (bad), varies. PDF.js is the only column with no bad cell.built-inPDF.jsSDKimagesJS cost0 KB500 KBMBs20 KBown toolsnoyesyesbuild400% zoomsharpsharpsharpblurrylicensefreeApachepaidvariesred cells are the cost you accept with that option
Each option fails somewhere. PDF.js has no red cell for this product, but its 500 KB is why it must load lazily, only when a PDF is opened.

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.

Sequence diagram for rendering page 12, with four columns: PageView, Scheduler, pdf.js on the main thread, and the worker. PageView tells the Scheduler page 12 is visible. The Scheduler calls page.render. pdf.js asks the worker for the operator list. The worker replies with the operator list and fonts. pdf.js paints the canvas in chunks and reports render done to the Scheduler, which tells PageView to show the canvas. Failure path: if page 12 leaves the window first, PageView tells the Scheduler, and the Scheduler calls task.cancel.PageViewSchedulerpdf.js mainworkerpage 12 visiblepage.render()getOperatorListops + fontsrender doneshow canvasif scrolled off:page 12 lefttask.cancel()
Parsing happens in the worker; only painting runs on the main thread. A render is a task with a cancel(), so a fast scroll cancels pages it flew past instead of queueing them.
ts
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
The smallest useful PDF.js render: load, get a page, size the canvas for the screen, paint.

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 span per run of text, from page.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 CSS scaleX so 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 its TextLayer class.
  • Annotation layer: real DOM elements for interactive parts of the page: a elements for links, input, select, and textarea elements for form fields, popups for comments already in the file. PDF.js builds it with its AnnotationLayer class from page.getAnnotations().
The three layers of one page shown side by side. First, the canvas: painted lines of text and an image, pixels only. Second, the text layer: dashed boxes, one transparent span over each line of text. Third, the annotation layer: a real link element and a real input element placed over the link and the form field. The three stack in this order at the same size and position.1. canvas2. text layer3. annotationslinkinputpixels onlytransparent spansreal a and inputstacked in this order: same size, same position
Each layer has one job: the canvas is what you see, the text layer is what you select and search, and the annotation layer is what you click and type into.

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.

http
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"
One range request and its reply. 206 means the server sent part of the file.

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?

Download everything, then parse

fetch the URL, wait for the full ArrayBuffer, pass it as data to PDF.js.

Pros

  • Simplest. Works with any server, any headers.
  • Easy to cache the whole file in memory or IndexedDB.

Cons

  • Time to page 1 grows with file size: 48 seconds for 120 MB on 20 Mbps.
  • Peak memory holds the whole file at once.

Stream the full file and parse as it arrives

One GET; PDF.js reads the response body as a stream and parses objects as their bytes arrive.

Pros

  • Works without range support.
  • A linearized file shows page 1 after its first chunks arrive.

Cons

  • A normal (non-linearized) file still waits for the end, where the xref lives.
  • Downloads every byte even if the user reads one page.

Range requests on demand, plus background fetch

PDF.js starts a normal GET, reads the headers, and if the server supports ranges, asks for 64 KB chunks wherever the parser needs data: the end of the file, then page 1's objects.

Meanwhile it keeps fetching the remaining chunks in the background, unless told not to.

Pros

  • Page 1 after a few hundred KB for most files, linearized or not.
  • Jumping to page 800 fetches page 800's bytes, not the 799 before it.

Cons

  • Needs correct server headers and CORS setup.
  • Many small requests: each 64 KB chunk is a round trip, so HTTP/2 or HTTP/3 matters.

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.

Sequence diagram for opening a 120 MB PDF, with three columns: Viewer, PDF.js, and CDN. The Viewer opens the URL. PDF.js starts a streaming GET. The CDN replies 200 with Accept-Ranges bytes and a length of 120 MB. PDF.js requests the last 64 KB with a Range header and gets a 206 with the xref table. It requests page 1's objects and gets a 206. PDF.js tells the Viewer page 1 is ready. Failure path: if the first reply is gzip encoded or its headers are hidden by CORS, PDF.js can't use ranges, and the Viewer waits for all 120 MB.ViewerPDF.jsCDNopen urlGET, streaming200, ranges ok, 120 MBRange: last 64 KB206 xref tableRange: page 1 objects206 page 1page 1 readyor, no usable ranges:gzip or hidden headerswait for all 120 MB
With working ranges, page 1 costs a few hundred KB of a 120 MB file. The failure path is silent: no error, just a long wait, which is why the viewer reports whether ranges were used.

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-Length is 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: bytes is present.
  • No compression: Content-Encoding is absent or identity. 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-Origin for our origin, Access-Control-Allow-Headers: Range so a preflighted Range request is allowed, and Access-Control-Expose-Headers: Accept-Ranges, Content-Range, Content-Encoding, because JavaScript can't read headers outside the CORS-safelisted set (which already includes Content-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.

One A4 page. Memory doubles in each direction, so twice the zoom is four times the memory.
ZoomdevicePixelRatioCanvas size (device px)PixelsMemory
100%1794 x 11230.9 million3.6 MB
100%21588 x 22463.6 million14 MB
100%32382 x 33698.0 million32 MB
200%23176 x 449214.3 million57 MB
200%34764 x 673832.1 million128 MB
400%26352 x 898457.1 million228 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?

Render every page once, keep them all

Create all canvases on open, render in order.

Pros

  • Simple. Scrolling back is instant.

Cons

  • Memory grows with page count: 8.6 GB for 600 pages at 14 MB. Crashes on phones after about 10 to 20 pages at high density.

Visible pages only, destroy on exit

Render a page when it enters the viewport; free its canvas the moment it leaves.

Pros

  • Minimal memory.

Cons

  • Every scroll shows blank pages for 100 to 300 ms. Scrolling back and forth re-renders the same page again and again.

Visible pages plus a buffer, under a memory budget

Render visible pages first, then 1 or 2 pages ahead in the scroll direction.

Keep rendered pages in a least-recently-used cache. When the total canvas memory goes over the budget, free the page that was seen longest ago and is farthest from the viewport.

Cap each canvas's pixels; at the cap, render at lower resolution plus a detail canvas.

Pros

  • Bounded memory at any length and zoom.
  • Scrolling a page or two in either direction shows finished pages.

Cons

  • More code: a render queue, priorities, cancellation, and a cache.

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.

DevicePixel ratio
ZoomBuffer

Desktop, 100% zoom, device pixel ratio 2. Scroll or zoom to see canvases appear and get evicted.

Page virtualization lab. desktop, zoom 100%, pixel ratio 2, buffer 1. Visible pages: 1. Canvases kept: 2, using 29 MB of a 400 MB budget. One page canvas costs 14 MB, full sharpness.1visible2buffer3none4none5none6none7none8none9nonecanvas memory: 29 MB of 400 MB, 2 canvasesone page: 1588 x 2246 px = 14 MBunder the per-canvas cap: full sharpnesspages marked none are placeholders with no canvas
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.

State machine for one page. placeholder goes to rendering when the page enters the buffer. rendering goes back to placeholder when cancelled, or to rendered when done. rendered goes to stretched when the zoom changes, and stretched goes back to rendering when the zoom settles, while the old bitmap stays on screen. rendered goes to placeholder when evicted to stay under the memory budget.placeholderrenderingrenderedstretchedin buffercancelleddonezoom changeszoom settlesevictedone render at a time, most visible page first
Only 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?

Re-render on every zoom event

Each wheel or pinch event sets the new scale and re-renders visible pages at it.

Pros

  • Always sharp, in theory.

Cons

  • Renders are cancelled faster than they finish: flicker and blank pages. Burns CPU and battery.

CSS transform only

Scale the existing canvases with a CSS transform and never re-render.

Pros

  • Instant and smooth: the compositor scales bitmaps on the GPU.

Cons

  • Blurry when zooming in: a 100% bitmap stretched to 300% shows its pixels. Wasteful when zooming out.

CSS scale now, sharp re-render after the gesture settles

During the gesture, resize page boxes and stretch the existing bitmaps with CSS, so the motion is instant.

When no zoom event has arrived for about 150 to 300 ms, re-render visible pages at the new scale in hidden canvases.

Swap each new canvas in only when its render finishes, so the user never sees a blank page.

Pros

  • Smooth during the gesture, sharp after it.
  • Cancelled renders are rare, because rendering starts only when input stops.

Cons

  • Briefly blurry while zooming in. Old and new canvases exist at the same time, so budget for both.

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.

Timeline of a pinch zoom from 0 to 1 second. Pinch: wheel events from 0 to 400 ms. Wait: a quiet period from 400 to 600 ms. Render: the sharp page renders into a hidden canvas from 600 to 850 ms. Screen: the old bitmap is stretched with CSS from 0 to 850 ms, then the sharp canvas is swapped in and shown from 850 ms on.pinchwheel eventswait200 msrenderrenderscreenold bitmap, stretchedsharp0400 ms600850 swap
The screen follows the fingers at once because only CSS changes. The expensive render starts after input stops and replaces the stretched bitmap only when it's finished, so there is never a blank page.

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.

ts
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 });
Zoom around an anchor point. Page gaps don't scale, so a careful version anchors to a page and an offset inside it.

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.

Sequence diagram for a pinch zoom with four columns: User, handler, store, scheduler. The user pinches with ctrl plus wheel. The handler sets zoom from 1.0 to 1.6 with the anchor point in the store. The store re-renders page boxes with CSS sizes and keeps the scroll anchored, which the user sees at once. The handler asks the scheduler for sharp renders after 200 quiet ms. The scheduler swaps in the sharp canvases. Failure path: if the user pinches again before the renders finish, the handler cancels the renders and the cycle starts over.Userhandlerstoreschedulerctrl + wheelzoom 1.6, anchorCSS resizeafter 200 mssharp swapor, pinch again:pinch againcancel renders
The store change is synchronous and cheap, so the screen follows the fingers. The scheduler's work is debounced and cancellable, so a second pinch throws away renders for a zoom level the user already left.

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-width in state and recompute the zoom when the container resizes (a ResizeObserver), 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 /Rotate entry.

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.

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.

ZoomRotate

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.

Coordinates lab. 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.PDF 0,0screen 794 x 1123 CSS pxPDF space, points, y up[72, 690, 222, 704]screen, CSS px, y down96, 184200 x 19store the top one,compute the bottom one
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?

Written into the PDF file

Add standard annotation objects (/Highlight, /Text) to the file and save a new version. PDF.js can write its own editor's annotations and form values with pdf.saveDocument().

Pros

  • Portable: any PDF reader shows them. One file to email or archive.

Cons

  • Every save rewrites or appends to a possibly 100 MB file. Two reviewers saving at once means two file versions to merge.
  • Per-user visibility, threads, and resolve states don't fit the format well.

Stored on our server, separate from the file

An annotations API keyed by document version, page, and PDF-space rectangles. The viewer draws them in its own layer above the text layer.

Pros

  • Small writes, one per annotation. Permissions, threads, mentions, and live updates are normal app features.
  • The original file stays untouched and its hash stays valid.

Cons

  • Other PDF readers don't see them. Needs an export step to bake them into a file.

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.

Architecture of the viewer. In the main thread: the PdfViewer shell above PageList above PageView; the viewer store above the scheduler above the document service; the finder below PageView, and the annotation store below the document service. The shell feeds PageList, which passes visible pages to PageView. The store sends zoom to the scheduler. PageView asks the scheduler for renders; the scheduler and the finder both go through the document service. The document service exchanges messages with the PDF.js worker and fetches byte ranges from the CDN. The annotation store talks to the API.main threadPdfViewerprops, layoutviewer storepage, zoom, URLPageListvirtualizerschedulerqueue, budgetPageView3 layersdoc servicewraps PDF.jsfindertext matchingannotationsstore + APIworkerPDF.jsparse, ops, textserversCDNPDF bytesAPIannotationsvisiblezoommessages
Only the document service touches PDF.js, and only the scheduler decides what gets painted. The annotation store never touches rendering: it deals in PDF-space rectangles and talks to its own API.
PartJobWhy it's separate
PdfViewer shellTakes props, owns the viewer store, lays out toolbar, sidebar, page list.The public component. Everything else is internal and replaceable.
Viewer storeCurrent 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 virtualizerPlaceholder boxes from page sizes, the visible set from scroll, anchoring on zoom.Pure layout math, testable without PDF.js.
PageViewOne page: canvas, text layer, annotation layer, our highlight layer.Mounts and unmounts as pages enter and leave the window.
Render schedulerPriority 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 serviceWraps 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 workerParses, decodes, produces operator lists and text content.Keeps heavy parsing off the main thread.
Find controllerExtracts and normalizes text, matches, tracks the current result.Has its own lifecycle (runs across all pages, not just visible ones).
Annotation store and API clientLoads, 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.

Data flow. Reading: the CDN sends 206 byte ranges to the document service, which passes bytes to the worker; the worker returns operator lists and text. The document service hands page proxies to the scheduler, which gives finished canvases to PageView. Writing: PageView sends highlight rectangles in PDF space to the annotation store, which POSTs them to the API; the API replies 201 with an id.CDNdoc serviceworkerrangesbytesops + textschedulerpage proxiesPageViewcanvasannotationsAPIPDF-space rectsPOST201, id
Reads flow left to right and down: bytes, then drawing commands, then pixels. Writes go the other way and carry PDF-space rectangles, never screen pixels.

The client-side data model

ts
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;
};
What the viewer keeps, and where. Sizes and rectangles are in PDF points unless named Css.

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.

tsx
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;
};
Props: a source, view state that can be controlled or not, events, and extension points.
  • Controlled or uncontrolled: pass defaultPage and let the viewer own it, or pass page and onPageChange when 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.
State machine for loading a document. idle goes to loading when src is set. loading goes to ready when page 1 is ready, to password when the file is encrypted, and to error when loading fails. password goes back to loading on submit. error goes back to idle on retry, which starts loading again. ready goes to refresh URL on a 403 from a range request, and back to ready with a new URL. A note lists the error kinds: network means retry, password means ask again, invalid means show a message.idleloadingreadyerrorpasswordrefresh URLsrcpage 1encryptedsubmitfailsretry403new URLnetwork: retrypassword: ask againinvalid: show message
Every state has something to show, so the host never sees a blank box. The 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.

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 Down and Page Up move by a page, Home and End to first and last, Ctrl plus + and - zoom (intercepted only while focus is in the viewer), Ctrl+F opens our find bar. Toolbar buttons are real button elements 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: reduce is 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.

MinutesWhat to doWhat to say out loud
0 to 5Scope: file sizes, scans, devices, annotations, who embeds it."If files can be 200 MB, range loading and memory caps drive the design."
5 to 10Engine 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 20Draw 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 32Deep 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 40Zoom, 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 45Security, 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 OffscreenCanvas transferred 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, no Content-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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next