PDF.js Deep Dive from Demo 7: WorkerTransport Initialization
October 27, 2026 · StreamPDF Team

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:
- What does
WorkerTransportprepare during construction? - After
Readyis sent, how do PDF bytes begin flowing to the worker? - Exactly when, and by whom, is
loadingTask.promiseresolved?
This article follows only the demo's main document-loading path; it does not cover every
WorkerTransportAPI. 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:
| Parameter | Value in the demo | Purpose in the transport |
|---|---|---|
messageHandler | Document-specific channel for d1 ↔ d1_worker | Sends and receives all document messages |
loadingTask | task, the loading task with docId = "d1" | Resolves/rejects task._capability; forwards progress and password callbacks |
networkStream | PDFFetchStream instance | Provides full and range readers |
params | transportParams | DOM, hardware acceleration, and loading parameters |
factory | transportFactory | Provides the canvas, filter, and binary data factories |
pagesMapper | Identity page mapping | Stores 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:
| Group | Message | Main-thread responsibility |
|---|---|---|
| Data supply | GetReader | Creates the full reader, reads on worker pulls, and returns bytes |
| Data supply | ReaderHeadersReady | Returns streaming, range, and content-length information |
| Data supply | GetRangeReader | Creates a range reader for [begin, end) |
| Document result | GetDoc | Creates PDFDocumentProxy and resolves loadingTask.promise |
| Loading error | DocException | Rejects the same promise |
| Password | PasswordRequest | Invokes the user's loadingTask.onPassword |
| Download status | DocProgress / DataLoaded | Reports loading progress and the total byte count |
| Subsequent rendering | StartRenderPage / commonobj / obj | Starts page drawing and receives objects such as fonts and images |
| Worker resource fetching | FetchBinaryData | Obtains 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 isGetReader,PasswordRequest, orDocException. 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 messagesThis 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, andReadycauses the worker to sendGetReader—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 sendGetRangeReaderon demand; - Range unsupported: continues reading the full stream and creates
LocalPdfManageronce 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 ENQUEUEThe 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
GetDocRequestreply was returned long ago and contained onlyworkerId, which was used to establish the document-specific channel; GetDocis 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:
- Updates
pagesMapper.pagesNumberfrom its initial value of0to756, providing the basis for the upper-bound page-number validation in Article 3'sgetPage(1); - Stores
_numPagesand_htmlForXfaon the transport for later processes such as document saving and XFA rendering; - Removes
htmlForXfafrom the publicpdfInfo, keeping the XFA HTML tree only inside the transport; - Creates
PDFDocumentProxyand uses it to resolveloadingTask._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 articleOnly 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:
| Message | Meaning | Effect on the demo |
|---|---|---|
GetDoc | The document manager is initialized, and the total page count and fingerprints are available | Resolves loadingTask.promise; getPage can be called |
DataLoaded | The complete PDF bytes have been loaded | Reports 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:
- The constructor saves
MessageHandler,loadingTask,networkStream, the three factories, andpagesMapper, and creates resource containers and empty caches; setupMessageHandler()registers message handlers for data supply, document results, progress, errors, and subsequent rendering;- The transport is attached to
task._transport, and after the destruction check passes, the main thread sendsReady; - Only after receiving
Readydoes the worker executesetupDocand createPDFWorkerStream; GetReaderestablishes the streaming data channel, whileReaderHeadersReadyreturns the three capabilities: range, streaming, and contentLength;- The main thread calls
networkStream.getFullReader()for the first time, actually initiating fetch, and returns bytes chunk by chunk according to worker demand throughsink.onPull; - The worker initializes
pdfManager, readsnumPagesandfingerprints, and proactively sendsGetDoc; - The main thread updates the total page count, creates
PDFDocumentProxy, and resolvesloadingTask._capability; - The demo's
awaitresumes withpdf, after which it can entergetPage(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
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026