StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 3: getPage

October 27, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 3: getPage

Introduction

In the previous article, we obtained the pdf variable via await loadingTask.promise. It is an instance of PDFDocumentProxy.

Back to the demo in the first article, lines 8-10:

const page = await pdf.getPage(1);

A numeric argument is passed in:

  • pageNumber: the page number; the first page is 1. The value is 1.

getPage returns a Promise that resolves to a PDFPageProxy object. In this article, we follow this call into the source code.

The getPage call

According to the definition in api.js, the execution flow is as follows.

PDFDocumentProxy.getPage: a one-line delegation

getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}

The pdf object itself is just a facade; the real logic lives on this._transport — the WorkerTransport instance created in article 2 (task._transport). Everything below happens inside WorkerTransport.getPage.

Step 1: Validate the arguments

if (
  !Number.isInteger(pageNumber) ||
  pageNumber <= 0 ||
  pageNumber > this.pagesMapper.pagesNumber
) {
  return Promise.reject(new Error("Invalid page request."));
}

Plugging in the demo values:

  • Number.isInteger(1) is true
  • 1 > 0
  • this.pagesMapper.pagesNumber is 756 — written from pdfInfo.numPages when the document becomes ready. The demo PDF in this article has 756 pages, so 1 <= 756.

Validation passes. If you pass 0, a negative number, a decimal, or a number beyond the page count, this returns a rejected promise immediately.

Q: Why use Promise.reject instead of throw? A: To keep the asynchronous semantics consistent. Callers of getPage always await a promise, so argument errors go through the same error channel. Callers don't have to handle both a synchronous throw and an asynchronous rejection.

Step 2: Convert the page number

const pageIndex = pageNumber - 1;                        // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0

Two numbering schemes appear here:

  • pageNumber: the page number from the user's perspective, 1-based, intuitive.
  • pageIndex: the internal page number, 0-based, used as an array index and cache key.

pagesMapper.getPageId(1) returns 1. PagesMapper was created with new PagesMapper() during argument parsing in article 2. It maintains the mapping between "the page number the user sees" and "the original document page ID". The demo never moves, deletes, or copies pages, so the mapping is an identity mapping (#pageNumberToId is null, and getPageId returns its input unchanged). Therefore newPageIndex === pageIndex === 0.

Q: Why go through PagesMapper at all? A: To support page reordering. In an editor scenario, users can move, delete, and copy pages, after which "page 3" may no longer be the original document's page 3. The worker side only understands the original document's page order, so what gets sent to the worker is newPageIndex, while the main-thread cache is keyed by the user-facing pageIndex. Under an identity mapping the two are identical and can be ignored.

Step 3: Check the cache

const cachedPromise = this.#pagePromises.get(0);
if (cachedPromise) {
  return cachedPromise;
}

#pagePromises is a Map<pageIndex, Promise<PDFPageProxy>>. On the first call there is no cache hit, so we continue.

Q: What does the cache actually do? A: In practice, calling await pdf.getPage(1) twice in a row returns the same PDFPageProxy object (page === page2 is true). The second call hits the cached promise directly and no message is sent to the worker.

Step 4: Send the GetPage message to the worker

const promise = this.messageHandler
  .sendWithPromise("GetPage", {
    pageIndex: 0, // newPageIndex
  })
  .then(pageInfo => { ... });
this.#pagePromises.set(0, promise);
return promise;

messageHandler is the MessageHandler created in article 2. The communication mechanism of sendWithPromise will be covered in a separate article. All you need to know here is that it sends the message to the worker thread and returns a promise that waits for the worker's reply.

When the worker receives GetPage (core/worker.js):

handler.on("GetPage", async function ({ pageIndex }) {
  const page = await pdfManager.getPage(pageIndex);
  const [rotate, ref, userUnit, view] = await Promise.all([
    pdfManager.ensure(page, "rotate"),
    pdfManager.ensure(page, "ref"),
    pdfManager.ensure(page, "userUnit"),
    pdfManager.ensure(page, "view"),
  ]);
  return {
    rotate,
    ref,
    refStr: ref?.toString() ?? null,
    userUnit,
    view,
  };
});

How the worker parses the page object internally will be covered in a separate article. The reply for the demo's first page is:

pageInfo = {
  rotate: 0,
  ref: { num: 4, gen: 0 }, // after structured cloning across threads, ref degrades to a plain object
  refStr: "4 0 R",
  userUnit: 1,
  view: [0, 0, 595, 842],
}

Q: Why only these 4 properties? A: They are all metadata of the page: rotation angle, object reference, unit conversion factor, and visible area. Reading them only requires parsing the page object dictionary, without touching the page content stream. The real content parsing (drawing commands) is deferred to the GetOperatorList message at render time. This is why getPage can return quickly.

These 4 properties map one-to-one to getters on PDFPageProxy: rotate, ref, userUnit, and view. The next article's getViewport will use two of them.

Step 5: Create the PDFPageProxy

Once the reply arrives, we enter the .then callback:

.then(pageInfo => {
  if (this.destroyed) {
    throw new Error("Transport destroyed");
  }
  if (pageInfo.refStr) {
    this.#pageRefCache.set("4 0 R", 0);
  }
  const page = new PDFPageProxy(0, pageInfo, this, this.pagesMapper, false);
  this.#pageCache.set(0, page);
  return page;
});

Three things happen in sequence:

  1. destroyed check: while waiting for the reply, the document may already have been destroyed (loadingTask.destroy()); if so, throw an error.
  2. #pageRefCache reverse-lookup cache: "4 0 R" → 0. Used by getPageIndex(ref) for reverse lookups — for example, when an outline link jumps to a page, the PDF internally locates pages by object reference (4 0 R), which must be translated back to a page number.
  3. Create the PDFPageProxy and store it in #pageCache.

The PDFPageProxy constructor only assigns fields; it does no parsing at all:

this._pageIndex = 0;
this._pageInfo = pageInfo;
this._transport = transport;
this._stats = null; // pdfBug is false, so performance stats are not enabled
this.commonObjs = transport.commonObjs; // resource container shared across the whole document (fonts, etc.)
this.objs = new PDFObjects(); // resource container private to this page
this._intentStates = new Map(); // rendering intent state cache, used by render in article 5
this.destroyed = false;

commonObjs and objs are resource containers used later during rendering; PDFObjects will be covered in a separate article.

At this point, the promise returned by getPage(1) resolves to page, and the demo's await receives it.

Summary

In this section we followed the demo through the complete getPage(1) call:

  1. PDFDocumentProxy.getPage delegates in one line to WorkerTransport.getPage.
  2. Argument validation: 1 is a valid page number (756 pages in total).
  3. Page number conversion: pageNumber(1) → pageIndex(0) → newPageIndex(0); under the identity mapping all three agree.
  4. The #pagePromises cache is checked and missed on the first call.
  5. A GetPage message is sent to the worker, and 4 pieces of page metadata are received.
  6. #pageRefCache is written, the PDFPageProxy is created, and #pageCache and #pagePromises are populated.
  7. The promise is returned, and the demo's await yields page.

Along the way we deliberately skipped two "heavyweight" components: the cross-thread communication of MessageHandler.sendWithPromise, and the worker-side page parsing of pdfManager.getPage. Each gets its own article.

With page in hand, the demo's next step is page.getViewport({ scale: 0.5 }) — which will use the view and userUnit from this article's pageInfo. That will be covered in the next article.

Related articles