StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive 2: getDocument & getPage

October 19, 2026 · StreamPDF Team

PDF.js Deep Dive 2: getDocument & getPage

Introduction

This is the second article in the PDF.js Deep Dive series, continuing from the architecture overview in article 1. We'll dive into the Display layer and trace the execution paths of getDocument and getPage. All code snippets in this article are based on PDF.js v6.3.289.

The getDocument Method

Step 1: Create the loading task and global ID generator

// Source: src/display/api.js
const task = new PDFDocumentLoadingTask();
const { docId } = task;

PDFDocumentLoadingTask also doubles as PDF.js's global ID generator — the docId it produces is used to identify this document throughout the worker.

Step 2: Process user parameters

const url = src.url ? getUrlProp(src.url) : null;
const data = src.data ? getDataProp(src.data) : null;
const httpHeaders = src.httpHeaders || null;
const withCredentials = src.withCredentials === true;
const password = src.password ?? null;
// ...
const transportFactory = {
  canvasFactory: new CanvasFactory({ ownerDocument, enableHWA }),
  filterFactory: new FilterFactory({ docId, ownerDocument }),
  binaryDataFactory:
    (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('MOZCENTRAL')) || useWorkerFetch
      ? null
      : new BinaryDataFactory({ cMapUrl, standardFontDataUrl, wasmUrl }),
};

Step 3: Create the Worker

if (!worker) {
  // Worker was not provided -- creating and owning our own. If message port
  // is specified in global worker options, using it.
  worker = PDFWorker.create({
    verbosity,
    port: GlobalWorkerOptions.workerPort,
  });
  task._worker = worker;
}

Callers can pass in their own Worker instance, but in the common case PDF.js handles Worker creation internally.

PDFWorker is a class; we invoke its static create method.

Entering getDocument → PDFWorker.create

// Source: src/display/api.js
static create(params) {
  const cachedPort = this.#workerPorts.get(params?.port);
  if (cachedPort) {
    if (cachedPort._pendingDestroy) {
      throw new Error(
        "PDFWorker.create - the worker is being destroyed.\n" +
          "Please remember to await `PDFDocumentLoadingTask.destroy()`-calls."
      );
    }
    return cachedPort;
  }
  return new PDFWorker(params);
}

PDFWorker maintains a static #workerPorts Map keyed by port, which caches previously created Worker instances so the same port isn't instantiated twice. If a cached entry exists, it returns it directly; otherwise it constructs a fresh PDFWorker.

Entering getDocument → PDFWorker.create → PDFWorker

// Source: src/display/api.js
constructor({
  name = null,
  port = null,
  verbosity = getVerbosityLevel(),
} = {}) {
  this.name = name;
  this.destroyed = false;
  this.verbosity = verbosity;
 
  if (port) {
    if (PDFWorker.#workerPorts.has(port)) {
      throw new Error("Cannot use more than one PDFWorker per port.");
    }
    PDFWorker.#workerPorts.set(port, this);
    this.#initializeFromPort(port);
  } else {
    this.#initialize();
  }
 
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    // For testing purposes.
    Object.defineProperty(this, "_webWorker", {
      get() {
        return this.#webWorker;
      },
    });
  }
}

Since we don't pass a port, the constructor calls #initialize.

Entering getDocument → PDFWorker.create → PDFWorker → #initialize

if (PDFWorker.#isWorkerDisabled || PDFWorker.#mainThreadWorkerMessageHandler) {
  this.#setupFakeWorker();
  return;
}
let { workerSrc } = PDFWorker;
// ...
if (
  typeof PDFJSDev !== 'undefined' &&
  PDFJSDev.test('GENERIC') &&
  !PDFWorker._isSameOrigin(window.location, workerSrc)
) {
  workerSrc = PDFWorker._createCDNWrapper(new URL(workerSrc, window.location).href);
}
const worker = new Worker(workerSrc, { type: 'module' });
const messageHandler = new MessageHandler('main', 'worker', worker);
// ...

First it checks whether to fall back to a "fake worker" (run PDF parsing on the main thread). In our case we take the normal path, skip the fake-worker branch, and read workerSrc from a static getter.

Entering getDocument → PDFWorker.create → PDFWorker → #initialize → workerSrc

static get workerSrc() {
  if (GlobalWorkerOptions.workerSrc) {
    return GlobalWorkerOptions.workerSrc;
  }
  throw new Error('No "GlobalWorkerOptions.workerSrc" specified.');
}

This is the option we set at startup:

pdfjsLib.GlobalWorkerOptions.workerSrc =
  'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.3.289/build/pdf.worker.min.mjs';

Back to getDocument → PDFWorker.create → PDFWorker → #initialize

Next, PDF.js handles a potential cross-origin issue: if workerSrc and the current page are on different origins, it wraps the URL in a Blob to bypass the Worker origin check. In our demo the worker is same-origin, so we skip this branch.

What is the Worker cross-origin issue?

The browser's native new Worker(url) only accepts same-origin script URLs. If workerSrc points to a CDN (e.g. https://cdn.jsdelivr.net/...) that differs from your page's origin, the browser rejects Worker creation outright with Failed to construct 'Worker'.

PDF.js's fix is _createCDNWrapper: it generates a tiny snippet of JS — await import("${url}"); — wraps it in a Blob, then calls URL.createObjectURL to produce a blob URL. A blob URL inherits the origin of the page that created it, so new Worker(blobUrl) looks same-origin to the browser. The wrapper's import call itself can load ES modules cross-origin, which gives PDF.js an indirect way around the restriction.

In our demo the worker script is same-origin, so _isSameOrigin returns true and this wrapper path is never triggered.

A few lines later the Worker is instantiated as an ES Module:

const worker = new Worker(workerSrc, { type: 'module' });

and the main-to-worker communication channel is created:

const messageHandler = new MessageHandler('main', 'worker', worker);

MessageHandler is an abstraction that wraps the underlying postMessage-based communication between the main thread and the Worker.

Worker ↔ main handshake

PDF.js then performs a short handshake between the two threads:

const terminateEarly = () => {
  ac.abort();
  messageHandler.destroy();
  worker.terminate();
  if (this.destroyed) {
    this.#capability.reject(new Error('Worker was destroyed'));
  } else {
    // Fall back to fake worker if the termination is caused by an
    // error (e.g. NetworkError / SecurityError).
    this.#setupFakeWorker();
  }
};
 
const ac = new AbortController();
worker.addEventListener(
  'error',
  () => {
    if (!this.#webWorker) {
      // Worker failed to initialize due to an error. Clean up and fall
      // back to the fake worker.
      terminateEarly();
    }
  },
  { signal: ac.signal },
);
 
messageHandler.on('test', data => {
  ac.abort();
  if (this.destroyed || !data) {
    terminateEarly();
    return;
  }
  this.#messageHandler = messageHandler;
  this.#port = worker;
  this.#webWorker = worker;
 
  this.#resolve();
});
 
messageHandler.on('ready', data => {
  ac.abort();
  if (this.destroyed) {
    terminateEarly();
    return;
  }
  try {
    sendTest();
  } catch {
    // We need fallback to a faked worker.
    this.#setupFakeWorker();
  }
});
 
const sendTest = () => {
  const testObj = new Uint8Array();
  // Ensure that we can use `postMessage` transfers.
  messageHandler.send('test', testObj, [testObj.buffer]);
};
 
// It might take time for the worker to initialize. We will try to send
// the "test" message immediately, and once the "ready" message arrives.
// The worker shall process only the first received "test" message.
sendTest();

It listens for error, ready, and test, then calls sendTest immediately to probe the channel.

Let's look at what happens on the Worker side when new Worker(...) is called.

Entering main → new Worker

// Source: src/core/worker.js
static {
  // Worker thread (and not Node.js)?
  if (
    typeof window === "undefined" &&
    !isNodeJS &&
    typeof self !== "undefined" &&
    /* isMessagePort = */
    typeof self.postMessage === "function" &&
    "onmessage" in self
  ) {
    this.initializeFromPort(self);
  }
}

The Worker's entry point is a static block inside WorkerMessageHandler. It detects that it's running in a Worker environment and calls initializeFromPort.

Entering main → new Worker → initializeFromPort

static initializeFromPort(port) {
  const handler = new MessageHandler("worker", "main", port);
  this.setup(handler, port);
  handler.send("ready", null);
}

It creates a MessageHandler for the worker→main direction, calls setup, and then signals ready back to main.

Entering main → new Worker → initializeFromPort → setup

// Source: src/core/worker.js (WorkerMessageHandler.setup)
static setup(handler, port) {
  let testMessageProcessed = false;
  handler.on("test", data => {
    if (testMessageProcessed) {
      return; // we already processed 'test' message once
    }
    testMessageProcessed = true;
 
    // Ensure that `TypedArray`s can be sent to the worker.
    handler.send("test", data instanceof Uint8Array);
  });
 
  handler.on("configure", data => {
    setVerbosityLevel(data.verbosity);
  });
 
  handler.on("GetDocRequest", data => this.createDocumentHandler(data, port));
 
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    handler.on("GetWorkerCoverage", function () {
      return globalThis.__coverage__ ?? {};
    });
  }
}

The worker registers handlers for three messages: test, configure, and GetDocRequest. The test handler is one-shot — it sets testMessageProcessed to true and echoes back.

Now back to the main thread: when the Worker's ready arrives, main resends test. But the Worker's testMessageProcessed flag is already true, so the retry is silently discarded. Only the very first test message gets a response. Once main receives that acknowledgement, it calls #resolve(), marking worker.promise as complete.

Here's the full handshake visualized:

main ↔ worker startup handshake sequence diagram

The two vertical lanes represent main (left) and worker (right), with time flowing downward.

① Right after new Worker, main sends a test probe that validates postMessage transfer semantics;

② After finishing initializeFromPort, the worker replies with ready, telling main it is now ready;

③ Main resends test after receiving ready, but the worker only processes the first one — testMessageProcessed is a one-shot switch. This retry (dashed in the diagram) is dropped at if (testMessageProcessed) { return; };

④ The worker responds only to the first test. Main then removes its error listener and calls #resolve(), completing worker.promise.

Q: Which test message is actually consumed, and why is the retry sent? A: The first one is always consumed — the ① probe fired right after new Worker. On the worker side, testMessageProcessed is a one-shot switch: when the first message arrives, the flag is false, so it flips to true and echoes back a test ack. Every subsequent test (the ③ retry in the diagram) hits if (testMessageProcessed) { return; } and is silently dropped.

Why can we be sure it's the first? Main and worker share a single ordered MessagePort. The first sendTest() (right after Worker creation) is guaranteed to be enqueued before the retry triggered by ready. The worker only starts dispatching the message queue after its initial script runs and setup() registers the test handler, so that first message is never "missed" — it always hits the one-shot switch.

Then why retry at all? The source code comment spells it out: "It might take time for the worker to initialize." The worker starts slower than the caller, and main doesn't know whether it's ready. PDF.js uses a "probe optimistically first + retry after ready" strategy. In the rare case where the first test isn't dispatched before the switch flips, the retry acts as a fallback. The testMessageProcessed flag ensures the retry can't produce a duplicate ack — keeping the whole process idempotent.

On top of that, try { sendTest(); } catch { this.#setupFakeWorker(); } in the ready callback also acts as a channel health check: if the message can't even be sent, the Worker channel is dead and PDF.js falls back to the fake worker. So the retry is fundamentally about handshake robustness, not about "the second try being the one that works."

Milestone ✅ Worker handshake complete: main creates the Worker thread, confirms the channel works through the test / ready / test exchange, and the Worker instance is ready.

main → Worker: GetDocRequest

Once the handshake is done, main waits for both the Worker and GPU to be ready — the GPU check (gpuPromise) determines whether certain rendering paths can use hardware acceleration later.

Promise.all([worker.promise, gpuPromise])
  .then(function ([, hasGPU]) {
    if (worker.destroyed) {
      throw new Error("Worker was destroyed");
    }
 
    docParams.evaluatorOptions.hasGPU = hasGPU;
 
    const workerIdPromise = worker.messageHandler.sendWithPromise(
      "GetDocRequest",
      docParams,
      data ? [data.buffer] : null
    );

Main sends a GetDocRequest message through the Worker-level messageHandler.

The Worker's handler is registered in setup:

Worker: on GetDocRequest

handler.on('GetDocRequest', data => this.createDocumentHandler(data, port));

This triggers createDocumentHandler:

Worker: on GetDocRequest → createDocumentHandler

const workerHandlerName = docId + '_worker';
let handler = new MessageHandler(workerHandlerName, docId, port);

After validating the parameters, it creates a document-specific MessageHandler between main and this document on the worker, registers a long list of message handlers (GetPage, GetDestinations, GetMetadata, GetOutline, Terminate, etc.), and returns workerHandlerName back to main.

Back in getDocument:

} else if (url) {
  if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
    throw new Error("Not implemented: NetworkStream");
  }
  const NetworkStream = getNetworkStream(url);
 
  networkStream = new NetworkStream({
    url,
    httpHeaders,
    withCredentials,
    rangeChunkSize,
    disableRange,
    disableStream,
  });
} else {

Since we provided a url, PDF.js instantiates a NetworkStream. It then waits for workerIdPromise to resolve:

return workerIdPromise.then(workerId => {
  if (worker.destroyed) {
    throw new Error('Worker was destroyed');
  }
 
  const messageHandler = new MessageHandler(docId, workerId, worker.port);
  const transport = new WorkerTransport(
    messageHandler,
    task,
    networkStream,
    transportParams,
    transportFactory,
    pagesMapper,
  );
  task._transport = transport;
 
  if (task.destroyed) {
    // `destroy()` was called during the worker handshake; the orderly
    // shutdown (including the "Terminate" message) will be issued
    // through the transport once destroy resumes.
    throw new Error('Loading aborted');
  }
  messageHandler.send('Ready', null);
});

Main creates the document-level MessageHandler and a WorkerTransport — the central object that manages this document's lifecycle. Finally it sends the Ready message to kick off actual loading.

Milestone ✅ GetDocRequest complete: Worker registers an independent message-processing chain for this document and returns workerHandlerName; main creates WorkerTransport and sends Ready to begin loading.

main → Worker: Ready

Entering Worker: on Ready → setupDoc

// Source: src/core/worker.js
handler.on('Ready', function () {
  setupDoc(docParams);
  docParams = null; // we don't need docParams anymore -- saving memory.
});

setupDoc is the core setup function. It defines three helpers — onSuccess, onFailure, and pdfManagerReady — then calls getPdfManager:

Entering Worker: on Ready → setupDoc → getPdfManager

const pdfManagerArgs = {
  source: null,
  disableAutoFetch,
  docBaseUrl,
  docId,
  enableXfa,
  evaluatorOptions,
  handler,
  length: 0,
  password,
  rangeChunkSize,
};
 
if (data) {
  pdfManagerArgs.source = data;
 
  return new LocalPdfManager(pdfManagerArgs);
}
const pdfStream = new PDFWorkerStream({ msgHandler: handler }),
  fullReader = pdfStream.getFullReader();

When raw data bytes are provided, PDF.js uses LocalPdfManager. For URL-based loading it creates a PDFWorkerStream to read data incrementally.

Entering Worker: on Ready → setupDoc → getPdfManager → PDFWorkerStream

class PDFWorkerStream extends BasePDFStream {
  constructor(source) {
    super(source, PDFWorkerStreamReader, PDFWorkerStreamRangeReader);
  }
}

PDFWorkerStream subclasses BasePDFStream and wires in two reader implementations.

Back to Worker: on Ready → setupDoc → getPdfManager

fullReader = pdfStream.getFullReader();

getFullReader is inherited from BasePDFStream:

getFullReader() {
  assert(
    !this._fullReader,
    "BasePDFStream.getFullReader can only be called once."
  );
  return (this._fullReader = new this.#PDFStreamReader(this));
}

It instantiates PDFWorkerStreamReader, which — crucially — sends a GetReader message back to main over the msgHandler:

class PDFWorkerStreamReader extends BasePDFStreamReader {
  _reader = null;
 
  constructor(stream) {
    super(stream);
    const { msgHandler } = stream._source;
 
    const readableStream = msgHandler.sendWithStream('GetReader');
    this._reader = readableStream.getReader();
 
    msgHandler.sendWithPromise('ReaderHeadersReady').then(data => {
      this._contentLength = data.contentLength;
      this._isStreamingSupported = data.isStreamingSupported;
      this._isRangeSupported = data.isRangeSupported;
 
      this._headersCapability.resolve();
    }, this._headersCapability.reject);
  }
  // ...
}

On the main side, WorkerTransport handles the GetReader message by calling networkStream.getFullReader(), which eventually issues the first actual HTTP request — the fetch call for the PDF itself:

constructor(stream) {
  super(stream);
  const {
    disableRange,
    disableStream,
    rangeChunkSize,
    url,
    withCredentials,
  } = stream._source;
 
  this._isStreamingSupported = !disableStream;
  const headers = new Headers(stream.headers);
 
  fetchUrl(url, headers, withCredentials, this._abortController)
    .then(response => {
      stream._responseOrigin = getResponseOrigin(response.url);
      ensureResponseStatus(response.status, url);
      this._reader = response.body.getReader();
 
      const responseHeaders = response.headers;
      const { contentLength, isRangeSupported } =
        validateRangeRequestCapabilities({
          responseHeaders,
          isHttp: true,
          rangeChunkSize,
          disableRange,
        });
      this._contentLength = contentLength;
      this._isRangeSupported = isRangeSupported;
      this._filename = extractFilenameFromHeader(responseHeaders);
 
      // We need to stop reading when range is supported and streaming is
      // disabled.
      if (!this._isStreamingSupported && this._isRangeSupported) {
        this.cancel(new AbortException("Streaming is disabled."));
      }
 
      this._headersCapability.resolve();
    })
    .catch(this._headersCapability.reject);
}

This is where the first network request happens. PDF.js fetches the URL and inspects response headers — content-length, Accept-Ranges, Content-Disposition, etc.

One important detail:

// We need to stop reading when range is supported and streaming is
// disabled.
if (!this._isStreamingSupported && this._isRangeSupported) {
  this.cancel(new AbortException('Streaming is disabled.'));
}

If range requests are available but streaming is off, PDF.js deliberately aborts this initial fetch. You may see the request fire and then immediately cancel — that's not a bug, it's intentional: the probe request only exists to determine whether range is supported. Once that decision is made, PDF.js either switches to NetworkPdfManager (range-capable, reads on-demand) or LocalPdfManager (downloads everything).

This is one of the optimizations built into StreamPDF: our backend guarantees that OSS always supports range requests, eliminating the need for this probe request and moving straight to the next phase.

Back in getPdfManager, once headers are known:

fullReader.headersReady.then(() => {
  if (!fullReader.isRangeSupported) {
    return;
  }
  pdfManagerArgs.source = pdfStream;
  pdfManagerArgs.length = fullReader.contentLength;
  pdfManagerArgs.disableAutoFetch ||= fullReader.isStreamingSupported;
 
  newPdfManager = new NetworkPdfManager(pdfManagerArgs);
  // ...
  resolve(newPdfManager);
});

If range is supported → NetworkPdfManager (reads data incrementally over the network). If not → the probe request finishes normally, all bytes are buffered, and LocalPdfManager is used instead.

async function readData() {
  let loaded = 0;
  while (true) {
    const { value, done } = await fullReader.read();
    ensureNotTerminated();
    if (done) break;
    // ...
    if (!fullReader.isStreamingSupported) {
      handler.send('DocProgress', { loaded, total: fullReader.contentLength });
    }
    if (newPdfManager) {
      newPdfManager.sendProgressiveData(value);
    } else {
      cachedChunks.push(value);
    }
  }
  if (!newPdfManager) {
    pdfManagerArgs.source = arrayBuffersToBytes(cachedChunks);
    newPdfManager = new LocalPdfManager(pdfManagerArgs);
    resolve(newPdfManager);
  }
}
readData();

Q: Why "LocalPdfManager"? Because once all bytes are in memory, the code path is identical to handling a locally-provided PDF — "local" means the data is fully available, not that it came from disk.

Once getPdfManager resolves, setupDoc continues:

getPdfManager(data)
  .then(function (newPdfManager) {
    // ...
    pdfManager = newPdfManager;
 
    pdfManager.requestLoadedStream(/* noFetch = */ true).then(
      stream => {
        handler.send('DataLoaded', { length: stream.bytes.byteLength });
      },
      () => {
        /* avoid errors if terminated */
      },
    );
  })
  .then(pdfManagerReady, onFailure);

Then pdfManagerReady → loadDocument:

Entering Worker: on Ready → pdfManagerReady → loadDocument

// Source: src/core/worker.js (closure inside createDocumentHandler)
async function loadDocument(recoveryMode) {
  await pdfManager.initDocument(recoveryMode);
 
  const isPureXfa = await pdfManager.ensureDoc('isPureXfa');
  if (isPureXfa) {
    const task = new WorkerTask('loadXfaResources');
    startWorkerTask(task);
    await pdfManager.ensureDoc('loadXfaResources', [handler, task]);
    finishWorkerTask(task);
  }
 
  const [numPages, fingerprints] = await Promise.all([
    pdfManager.ensureDoc('numPages'),
    pdfManager.ensureDoc('fingerprints'),
  ]);
 
  const htmlForXfa = isPureXfa ? await pdfManager.ensureDoc('htmlForXfa') : null;
 
  return { numPages, fingerprints, htmlForXfa };
}

Inside initDocument (inherited from BasePdfManager), PDF.js performs a series of integrity checks — parse the header, parse the cross-reference table, parse the document, verify the first and last page load correctly. We won't go into the details here; that will be the topic of a future article.

Once all checks pass, onSuccess is called, which sends the document metadata back to main:

function onSuccess(doc) {
  ensureNotTerminated();
  handler.send('GetDoc', { pdfInfo: doc });
}

Milestone ✅ Ready complete: Worker initializes the PdfManager, probes network capabilities (range / streaming) via fetch, creates either NetworkPdfManager or LocalPdfManager, runs document integrity checks, and sends document metadata back to main with the GetDoc message.

Worker → main: GetDoc

Entering main: on GetDoc

messageHandler.on('GetDoc', ({ pdfInfo }) => {
  this.pagesMapper.pagesNumber = pdfInfo.numPages;
  this._numPages = pdfInfo.numPages;
  this._htmlForXfa = pdfInfo.htmlForXfa;
  delete pdfInfo.htmlForXfa;
  loadingTask._capability.resolve(new PDFDocumentProxy(pdfInfo, this));
});

Main stores the document metadata, creates a PDFDocumentProxy — the Display-layer facade for the document — and resolves loadingTask.promise.

Milestone ✅ GetDoc complete: main receives document metadata, creates PDFDocumentProxy and resolves loadingTask.promise. The getDocument call chain is fully resolved.

At this point getDocument returns, and the caller receives a PDFDocumentProxy.

The getPage Method

From our demo code:

const loadingTask = pdfjsLib.getDocument({ url: '...' });
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);

pdf is a PDFDocumentProxy instance, so pdf.getPage(1) calls PDFDocumentProxy.getPage.

Entering main: PDFDocumentProxy.getPage

// Source: src/display/api.js
getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}

It delegates to WorkerTransport.getPage:

Entering main: PDFDocumentProxy.getPage → WorkerTransport.getPage

// Source: src/display/api.js
getPage(pageNumber) {
  if (
    !Number.isInteger(pageNumber) ||
    pageNumber <= 0 ||
    pageNumber > this.pagesMapper.pagesNumber
  ) {
    return Promise.reject(new Error("Invalid page request."));
  }
  const pageIndex = pageNumber - 1;
  const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1;
 
  const cachedPromise = this.#pagePromises.get(pageIndex);
  if (cachedPromise) {
    return cachedPromise;
  }
  const promise = this.messageHandler
    .sendWithPromise("GetPage", { pageIndex: newPageIndex })
    .then(pageInfo => {
      if (this.destroyed) { throw new Error("Transport destroyed"); }
      if (pageInfo.refStr) {
        this.#pageRefCache.set(pageInfo.refStr, newPageIndex);
      }
 
      const page = new PDFPageProxy(
        pageIndex, pageInfo, this, this.pagesMapper, this._params.pdfBug
      );
      this.#pageCache.set(pageIndex, page);
      return page;
    });
  this.#pagePromises.set(pageIndex, promise);
  return promise;
}

After validating the page number and checking an in-flight promise cache, main sends the GetPage message to the worker.

main → Worker: GetPage

Entering Worker: on GetPage

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 };
});

This calls pdfManager.getPage, which goes through BasePdfManager.getPage:

getPage(pageIndex) {
  return this.pdfDocument.getPage(pageIndex);
}

Which lands in PDFDocument.getPage:

Entering Worker: on GetPage → pdfManager.getPage → PDFDocument.getPage

// Source: src/core/document.js
getPage(pageIndex) {
  const cachedPromise = this.#pagePromises.get(pageIndex);
  if (cachedPromise) {
    return cachedPromise;
  }
  const { catalog, linearization, xfaFactory } = this;
 
  let promise;
  if (xfaFactory) {
    promise = Promise.resolve([Dict.empty, null]);
  } else if (linearization?.pageFirst === pageIndex) {
    promise = this.#getLinearizationPage(pageIndex);
  } else {
    promise = catalog.getPageDict(pageIndex);
  }
  promise = promise.then(
    ([pageDict, ref]) =>
      new Page({
        pdfManager: this.pdfManager,
        xref: this.xref,
        pageIndex,
        pageDict,
        ref,
        globalIdFactory: this._globalIdFactory,
        fontCache: catalog.fontCache,
        builtInCMapCache: catalog.builtInCMapCache,
        standardFontDataCache: catalog.standardFontDataCache,
        globalColorSpaceCache: catalog.globalColorSpaceCache,
        globalImageCache: catalog.globalImageCache,
        systemFontCache: catalog.systemFontCache,
        nonBlendModesSet: catalog.nonBlendModesSet,
        xfaFactory,
      })
  );
 
  this.#pagePromises.set(pageIndex, promise);
  return promise;
}

This is where PDF.js actually fetches and parses the PDF data for a given page, and constructs a Page instance. We won't go into the deep PDF parsing logic here — that will get its own article.

Back on the main side, WorkerTransport.getPage (code shown above) receives the pageInfo payload and wraps it in a PDFPageProxy, storing it in the page cache and returning it to the caller.

Milestone ✅ GetPage complete: main sends GetPage → Worker retrieves the Page instance from PDFDocument and extracts rotate / view / ref / userUnit → main receives pageInfo, wraps it in PDFPageProxy, and returns it.

Summary

In this article we traced the complete execution paths of getDocument and getPage:

  • getDocument → creates a PDFDocumentLoadingTask, spins up a Worker and completes the test / ready / test handshake, sends GetDocRequest to obtain a document-scoped message channel, sends Ready to kick off loading. The Worker probes network capability via fetch, creates NetworkPdfManager or LocalPdfManager, validates the document, and replies with GetDoc. Main receives metadata and resolves with a PDFDocumentProxy.
  • getPage → validates the page number, sends GetPage to the Worker. The Worker resolves the page's PDF dictionary and constructs a Page instance, extracting rotate / view / ref / userUnit. Main wraps the response in a PDFPageProxy and returns it.

At the end of both calls, our demo code has everything it needs:

const loadingTask = pdfjsLib.getDocument({
  url: 'https://assets.streampdf.com/PDF32000_2008.pdf',
});
const pdf = await loadingTask.promise;
const page = await pdf.getPage(1);

We end up holding a PDFPageProxy, which is the foundation for everything that comes next — viewport calculation, rendering to canvas, text extraction, annotations, and more.

Notice we've deliberately stayed at the top of the architecture. Before diving into PDF-level parsing (lexer, XRef table, page dictionary, operators), you need this high-level mental model of how data flows between main and Worker. If you stick with this series, you'll gain both a deep understanding of PDF.js internals and, by extension, a solid grasp of the PDF specification itself.

In the next article we'll move into viewport computation and rendering.

Related articles