StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 7: WorkerTransport Initialization

October 27, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 7: WorkerTransport Initialization

Introduction

Article 6 explained how MessageHandler transports messages, but it does not itself understand the business meaning of names such as GetDoc and GetReader. The component responsible for interpreting these messages is the subject of this article: WorkerTransport.

Returning to the end of the handshake in Article 2, after the worker returns workerId in response to GetDocRequest, the main thread executes:

const messageHandler = new MessageHandler(docId, workerId, worker.port);
const transport = new WorkerTransport(
  messageHandler,
  task,
  networkStream,
  transportParams,
  transportFactory,
  pagesMapper
);
task._transport = transport;
 
if (task.destroyed) {
  throw new Error("Loading aborted");
}
messageHandler.send("Ready", null);

This article starts at new WorkerTransport(...) and continues through this code from the first article's demo:

const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;

It answers three questions:

  1. What does WorkerTransport prepare during construction?
  2. After Ready is sent, how do PDF bytes begin flowing to the worker?
  3. Exactly when, and by whom, is loadingTask.promise resolved?

This article follows only the demo's main document-loading path; it does not cover every WorkerTransport API. For a complete classification of fields and messages, see the api.js source code walkthrough.

The WorkerTransport Call

Based on the definition of WorkerTransport in src/display/api.js, initialization can be divided into three steps: "storing dependencies," "registering messages," and "waiting for Ready."

Step 1: The Six Constructor Arguments Take Their Places

The demo passes only url to getDocument, but the preceding parameter parsing and handshake have already prepared six dependencies for the main thread:

ParameterValue in the demoPurpose in the transport
messageHandlerDocument-specific channel for d1 ↔ d1_workerSends and receives all document messages
loadingTasktask, the loading task with docId = "d1"Resolves/rejects task._capability; forwards progress and password callbacks
networkStreamPDFFetchStream instanceProvides full and range readers
paramstransportParamsDOM, hardware acceleration, and loading parameters
factorytransportFactoryProvides the canvas, filter, and binary data factories
pagesMapperIdentity page mappingStores the total page count and maintains the mapping between user page numbers and source document page order

Here, workerId is the document-specific worker name created by createDocumentHandler. For the first document in the demo, docId = "d1", so workerId = "d1_worker".

The constructor first saves the dependencies and creates resource containers on the main thread:

this.messageHandler = messageHandler;
this.loadingTask = loadingTask;
this.#networkStream = networkStream;
 
this.commonObjs = new PDFObjects();
this.fontLoader = new FontLoader({
  ownerDocument: params.ownerDocument,
  styleElement: params.styleElement,
});
this.enableHWA = params.enableHWA;
this.loadingParams = params.loadingParams;
this._params = params;
 
this.canvasFactory = factory.canvasFactory;
this.filterFactory = factory.filterFactory;
this.binaryDataFactory = factory.binaryDataFactory;
this.pagesMapper = pagesMapper;
 
this.destroyed = false;
this.destroyCapability = null;

In addition, three page caches and one method cache are created when the class fields are declared:

#methodPromises = new Map();
#pageCache = new Map();
#pagePromises = new Map();
#pageRefCache = new Map();

They are all empty Maps at this point. The #pagePromises, #pageCache, and #pageRefCache used by getPage(1) in Article 3 are prepared here.

Note: The constructor does not read the PDF or call networkStream.getFullReader(). It only assembles the dependencies and containers needed for subsequent loading, rendering, and destruction.

Step 2: setupMessageHandler Registers Business Routes

After assigning the fields, the constructor calls:

this.setupMessageHandler();

It registers business handlers on the main thread through a series of messageHandler.on(name, handler) calls. Grouped by when they execute in the demo:

GroupMessageMain-thread responsibility
Data supplyGetReaderCreates the full reader, reads on worker pulls, and returns bytes
Data supplyReaderHeadersReadyReturns streaming, range, and content-length information
Data supplyGetRangeReaderCreates a range reader for [begin, end)
Document resultGetDocCreates PDFDocumentProxy and resolves loadingTask.promise
Loading errorDocExceptionRejects the same promise
PasswordPasswordRequestInvokes the user's loadingTask.onPassword
Download statusDocProgress / DataLoadedReports loading progress and the total byte count
Subsequent renderingStartRenderPage / commonobj / objStarts page drawing and receives objects such as fonts and images
Worker resource fetchingFetchBinaryDataObtains binary resources such as CMaps and fonts through binaryDataFactory

Registration does not mean execution. The demo currently concerns only the data-supply and document-result messages; StartRenderPage is not used until render in Article 5.

Design note: Register all handlers first, then send Ready. Once the worker starts loading, the main thread will have a corresponding receiver regardless of whether the first message is GetReader, PasswordRequest, or DocException. Reversing this order would create a race in which a message arrives before the routing table is ready.

Step 3: Attach to task, Then Send Ready

After WorkerTransport returns, getDocument first assigns task._transport = transport, checks task.destroyed once more, and only then sends:

messageHandler.send("Ready", null);

This check handles a destruction race during the handshake: the user may already have called loadingTask.destroy() during an asynchronous wait. If the document has been destroyed, the worker should not be allowed to start loading it.

The demo does not call destroy, so Ready is sent normally. It is a pure action, as described in Article 6: it needs no reply and only notifies the worker that "the main thread's message routing and data supply are ready."

After Ready: The Worker Starts Requesting the PDF

Step 4: Ready Triggers setupDoc

While handling GetDocRequest earlier, the worker already created the document-specific MessageHandler and registered the Ready handler, but it has not yet started parsing:

handler.on("Ready", function () {
  setupDoc(docParams);
  docParams = null; // No longer needed; release the parameter bundle
});

Only after receiving Ready from the main thread does setupDoc(docParams) start. In the demo, docParams.data = null, so the worker cannot directly obtain the PDF bytes and must establish a reverse data channel:

The worker parser needs bytes
  → Requests them from the main thread via messages
  → The main thread reads from networkStream
  → Transfers the ArrayBuffer to the worker via messages

This is why the network stream lives on the main thread while the parser runs in the worker.

Step 5: PDFWorkerStream Sends GetReader

setupDoc calls getPdfManager(data). In the demo branch where data = null, the worker creates its own stream object:

const pdfStream = new PDFWorkerStream({ msgHandler: handler });
const fullReader = pdfStream.getFullReader();

PDFWorkerStream is located in src/core/worker_stream.js. It does not access the network; it only translates reader operations into messages. The full reader's constructor immediately does two things:

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

The two messages have different responsibilities:

  • GetReader: establishes a long-lived streaming byte channel;
  • ReaderHeadersReady: a one-shot request-response that obtains the length and loading capabilities.

These correspond to the three message shapes from Article 6: the former is sendWithStream, and the latter is sendWithPromise.

Step 6: The Main Thread Creates the Actual Network Reader

The main thread's GetReader handler begins executing:

messageHandler.on("GetReader", (data, sink) => {
  this.#fullReader = this.#networkStream.getFullReader();
  this.#fullReader.onProgress = evt => this.#onProgress(evt);
 
  sink.onPull = () => {
    this.#fullReader.read().then(({ value, done }) => {
      if (done) {
        sink.close();
        return;
      }
      sink.enqueue(new Uint8Array(value), 1, [value]);
    }).catch(reason => {
      sink.error(reason);
    });
  };
 
  sink.onCancel = reason => {
    this.#fullReader.cancel(reason);
    // Subsequent sink.ready cleanup omitted
  };
});

Only when networkStream.getFullReader() is called is the demo's PDFFetchStreamReader created and fetch(url) initiated. In other words:

getDocument({ url }) creates the network stream configuration, new WorkerTransport(...) registers the data handlers, and Ready causes the worker to send GetReader—only at this point does the HTTP request actually begin.

ReaderHeadersReady, meanwhile, waits for this request to receive its response headers:

messageHandler.on("ReaderHeadersReady", async data => {
  await this.#fullReader.headersReady;
 
  const { isStreamingSupported, isRangeSupported, contentLength } =
    this.#fullReader;
 
  if (isStreamingSupported && isRangeSupported) {
    this.#fullReader.onProgress = null;
  }
  return { isStreamingSupported, isRangeSupported, contentLength };
});

The worker uses these three values to decide its subsequent strategy:

  • Range supported: creates NetworkPdfManager, which can later send GetRangeReader on demand;
  • Range unsupported: continues reading the full stream and creates LocalPdfManager once all data is available.

The demo's rangeChunkSize = 65536, disableRange = false, and disableStream = false participate in this decision, but whether range can be used also depends on the response headers. The specific probing and request strategies are covered in Article 9.

Step 7: onPull Forms an On-Demand Byte Pump

The sink.onPull in the GetReader handler does not execute proactively upon registration. It is triggered when the worker's ReadableStream needs more data:

worker fullReader.read()
  → MessageHandler sends PULL
  → Main thread sink.onPull()
  → PDFFetchStreamReader.read()
  → fetch ReadableStream returns a chunk of bytes
  → sink.enqueue(new Uint8Array(value), 1, [value])
  → worker receives ENQUEUE

The third argument to sink.enqueue, [value], marks the underlying ArrayBuffer as Transferable: ownership of the buffer passes to the worker, avoiding a copy of the PDF data.

Thus, the main thread does not push the entire file to the worker at once. Instead, the worker pulls it chunk by chunk according to its consumption capacity. When done = true, the main thread calls sink.close(), ending the byte channel.

GetDoc: The Resolution Point for loadingTask.promise

Step 8: The Worker Parses Document-Level Metadata

Once the data channel is established, setupDoc waits for getPdfManager to provide pdfManager, then enters pdfManagerReady → loadDocument(false).

The part of loadDocument relevant to this article's main path is:

async function loadDocument(recoveryMode) {
  await pdfManager.initDocument(recoveryMode);
 
  // XFA branch omitted
  const [numPages, fingerprints] = await Promise.all([
    pdfManager.ensureDoc("numPages"),
    pdfManager.ensureDoc("fingerprints"),
  ]);
  const htmlForXfa = isPureXfa
    ? await pdfManager.ensureDoc("htmlForXfa")
    : null;
 
  return { numPages, fingerprints, htmlForXfa };
}

On success, the worker sends:

handler.send("GetDoc", {
  pdfInfo: { numPages, fingerprints, htmlForXfa },
});

For the demo document, numPages = 756; for an ordinary non-XFA document, htmlForXfa = null. fingerprints is an array of document identifiers.

This sends GetDoc; it is not the callback for GetDocRequest:

  • The GetDocRequest reply was returned long ago and contained only workerId, which was used to establish the document-specific channel;
  • GetDoc is a pure action that the worker later sends on its own. It contains document metadata and announces that "the document is ready for operations."

Step 9: The Main Thread Creates PDFDocumentProxy

The previously registered GetDoc handler on the main thread receives pdfInfo:

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

It performs four tasks in sequence:

  1. Updates pagesMapper.pagesNumber from its initial value of 0 to 756, providing the basis for the upper-bound page-number validation in Article 3's getPage(1);
  2. Stores _numPages and _htmlForXfa on the transport for later processes such as document saving and XFA rendering;
  3. Removes htmlForXfa from the public pdfInfo, keeping the XFA HTML tree only inside the transport;
  4. Creates PDFDocumentProxy and uses it to resolve loadingTask._capability.

The PDFDocumentProxy constructor is very lightweight:

constructor(pdfInfo, transport) {
  this._pdfInfo = pdfInfo;
  this._transport = transport;
}

It does not parse the PDF; it only saves the document information that has already arrived and a reference to the transport. Therefore, reading the total page count requires no additional message:

get numPages() {
  return this._pdfInfo.numPages; // 756
}

Only pdf.getPage(1) initiates a new GetPage request through this._transport.

Step 10: The Demo's await Resumes

loadingTask.promise is a getter for _capability.promise. After the previous step calls _capability.resolve(pdfDocumentProxy), the wait in the first article's demo ends:

const pdf = await loadingTask.promise;
// pdf is a PDFDocumentProxy
// pdf.numPages === 756
// pdf._transport === the WorkerTransport created in this article

Only then does the demo execute pdf.getPage(1) and enter the call chain from Article 3.

Document Ready Does Not Mean Download Complete

GetDoc and DataLoaded are two independent messages:

MessageMeaningEffect on the demo
GetDocThe document manager is initialized, and the total page count and fingerprints are availableResolves loadingTask.promise; getPage can be called
DataLoadedThe complete PDF bytes have been loadedReports 100% progress and resolves downloadInfoCapability

When range is supported, the worker may read only necessary regions such as the xref and object dictionaries before sending GetDoc, while the remaining page content stays on the server. Therefore, after await loadingTask.promise finishes, this does not mean the entire PDF has been downloaded, nor that the first page has been parsed or rendered.

When DataLoaded arrives, the main thread merely reports completion progress once more and records the download information:

messageHandler.on("DataLoaded", data => {
  this.#onProgress({ loaded: data.length, total: data.length });
  this.downloadInfoCapability.resolve(data);
});

The demo does not register loadingTask.onProgress, so the progress callback has no externally visible effect.

If loading fails, DocException rejects the same capability as GetDoc:

messageHandler.on("DocException", ex => {
  loadingTask._capability.reject(wrapReason(ex));
});

Thus, the demo's await loadingTask.promise has only two outcomes: GetDoc provides a PDFDocumentProxy, or DocException provides an exception. Encrypted documents also pass through PasswordRequest first; because the demo does not set onPassword, it eventually follows the same failure path when no password can be provided.

Summary

This article followed the demo from new WorkerTransport(...) to the completion of await loadingTask.promise:

  1. The constructor saves MessageHandler, loadingTask, networkStream, the three factories, and pagesMapper, and creates resource containers and empty caches;
  2. setupMessageHandler() registers message handlers for data supply, document results, progress, errors, and subsequent rendering;
  3. The transport is attached to task._transport, and after the destruction check passes, the main thread sends Ready;
  4. Only after receiving Ready does the worker execute setupDoc and create PDFWorkerStream;
  5. GetReader establishes the streaming data channel, while ReaderHeadersReady returns the three capabilities: range, streaming, and contentLength;
  6. The main thread calls networkStream.getFullReader() for the first time, actually initiating fetch, and returns bytes chunk by chunk according to worker demand through sink.onPull;
  7. The worker initializes pdfManager, reads numPages and fingerprints, and proactively sends GetDoc;
  8. The main thread updates the total page count, creates PDFDocumentProxy, and resolves loadingTask._capability;
  9. The demo's await resumes with pdf, after which it can enter getPage(1).

WorkerTransport is therefore not just a message forwarder: it also holds the network stream, document state, page caches, and font and canvas factories, making it the main thread's document runtime and business semantics layer. MessageHandler handles "how messages arrive"; WorkerTransport handles "what to do after they arrive."

Article 8 first returns to the PDFWorker.create hook in Article 2, covering worker creation, the ready/test handshake, and the fake-worker fallback. Article 9 then continues downward from this article's GetReader: how network streams probe range support, and when GetRangeReader actually issues HTTP Range requests. You can also first consult the network stream source code walkthrough to see all four implementations.

StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.

Related articles