StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 13: createDocumentHandler and setupDoc after Ready

October 29, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 13: createDocumentHandler and setupDoc after Ready

Introduction

The end of the handshake in Article 2 left two "click to view the event" hooks:

  1. The worker receives GetDocRequest and replies with workerId;
  2. The main thread sends Ready, and the worker starts loading the document upon receiving it.

The handlers for both events live not on the main thread, but in src/core/worker.js inside the worker thread. In previous articles the worker side stayed a black box: Article 7 only sketched setupDoc from the main thread's perspective, and Article 9 expanded the network stream. This article formally steps into the worker thread and, following the demo, answers three questions:

  1. What does the worker do upon GetDocRequest, and why is the reply exactly 'd0_worker'?
  2. What are the 20+ message handlers registered by createDocumentHandler?
  3. After Ready, how does setupDoc pick a PdfManager, initialize the document, and send GetDoc back?

This article only follows the demo's document-loading trunk. For the network stream and range-request strategy, see Article 9; for the xref parsing inside initDocument, see Deep Dive 6: PDFManager.initDocument and the pdf_manager source walkthrough.

The GetDocRequest event: createDocumentHandler

Line 102: the document entry on the global channel

Article 8 mentioned that on startup the worker registers only three handlers on the global worker ↔ main channel: test, configure, and GetDocRequest. The first two serve the boot handshake; the last one is the document entry point:

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

The main thread sends this message via sendWithPromise("GetDocRequest", docParams) (form two in Article 6), so the return value of createDocumentHandler is wrapped into a callback reply and sent back. The demo's docParams payload was assembled in Article 2: docId: 'd0', apiVersion: '6.3.289', data: null, rangeChunkSize: 65536, and so on.

Lines 111-130: version check

The first thing createDocumentHandler does is not building the channel, but validating:

const { docId, apiVersion } = docParams;
const workerVersion = PDFJSDev.eval("BUNDLE_VERSION"); // injected at build time
if (apiVersion !== workerVersion) {
  throw new Error(
    `The API version "${apiVersion}" does not match ` +
      `the Worker version "${workerVersion}".`
  );
}

The demo's apiVersion = '6.3.289' matches the build version of pdf.worker.mjs on the CDN, so the check passes.

Q: Why check the version again inside the worker? A: pdf.mjs and pdf.worker.mjs are two separate files; during deployment one can be updated while the other is served from a stale cache. With mismatched versions, the message protocol and parameter shapes may no longer line up. Throwing here makes the main thread receive an ERROR callback of form two, rejecting loadingTask.promise — failing early and unambiguously.

Lines 132-149: prototype-pollution guard

The GENERIC build keeps a peculiar-looking check:

for (const prop in {}) {
  throw new Error(buildMsg("Object", prop));
}
for (const prop in []) {
  throw new Error(buildMsg("Array", prop));
}

Running for...in over an empty object and an empty array normally never enters the loop body. Only when other scripts on the host page have incorrectly extended Object.prototype / Array.prototype does this enumerate the extra properties and throw immediately.

The source comment explains the motivation: PDF.js font parsing relies heavily on for...in enumeration semantics, and a polluted prototype chain makes fonts fail in cryptic ways. Rather than sprinkling Object.hasOwn all over hot paths, the code fails fast once at the document entry. The demo page does not pollute prototypes and passes through.

Lines 150-166: creating the d0_worker ↔ d0 document channel

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

This channel mirrors the main thread's new MessageHandler('d0', 'd0_worker', worker.port) from Article 2: the same port, with sourceName and targetName swapped. From this moment on, every message for this document travels over d0 ↔ d0_worker, fully separated from the global main ↔ worker channel — the two-layer channel table from Article 8 closes its loop here.

A set of closure variables is established alongside; they are the entire worker-side lifecycle of this document:

Closure variableInitial valueResponsibility
pdfManagerundefinedThe document manager, assigned only in setupDoc
terminatedfalseSet to true upon Terminate; every entry point checks it first
cancelXHRsnullCancels in-flight data requests; assigned in getPdfManager
WorkerTasksnew Set()Registry of terminatable task units (GetOperatorList, etc.)
verbosity1The current log level, used for timing statistics

startWorkerTask / finishWorkerTask simply add to / remove from this Set; upon Terminate, all registered tasks are terminated in one pass, so a destroyed document never keeps parsing in the background.

Lines 396-1044: registering 20+ business handlers

Next comes a registry spanning over 600 lines. Grouped by how much the demo cares about their timing:

GroupMessagesWorker-side duty
Page metadataGetPage, GetPageIndex, GetAnnotations, GetStructTreeParse page objects, return metadata or annotations
Catalog queriesGetOutline, GetDestinations, GetMetadata, GetPageLayout, GetPermissions, etc.Mostly one-line ensureCatalog(prop) forwards
Document dataGetData, SaveDocument, ExtractPagesFull bytes, incremental save, page extraction
Streaming evaluationGetOperatorList, GetTextContentParse and push into the sink as chunks (form three in Article 6)
Font fallbackFontFallbackFallback when a glyph fails to load on the main thread (Article 12)
LifecycleCleanup, Terminate, ReadyCache cleanup, document teardown, load kickoff

The demo's loading trunk only triggers Ready among these; GetPage and GetOperatorList are expanded in Articles 3 and 11 respectively. The remaining messages are never sent in the demo, but they share the same registry with no extra initialization.

Q: pdfManager is still undefined at registration time — won't handlers referencing it break? A: No. Handlers are closures referencing the variable itself; the value is read at call time. Every business message can only be sent by the main thread, and the prerequisite for sending them is that loadingTask.promise has resolved — by then setupDoc has long assigned pdfManager. Registration order and assignment timing are naturally staggered.

Line 1062: returning workerHandlerName

With the registry built, the function's last line:

return workerHandlerName; // 'd0_worker'

This return value crosses back as the callback reply — exactly the workerId = 'd0_worker' received at step 1 of Article 2. Mind the timing: at this point the worker has not read a single byte of the PDF; it has merely opened the "service window" for this document. Only after receiving workerId does the main thread build the mirror channel, create the WorkerTransport with its main-thread routes, and finally send Ready.

The Ready event: setupDoc starts loading

Lines 1041-1044: Ready triggers setupDoc

handler.on("Ready", function () {
  setupDoc(docParams);
  docParams = null; // we don't need docParams anymore -- saving memory.
});

Q: Why does the worker wait for Ready instead of starting as soon as GetDocRequest arrives? A: Because the moment loading starts, the worker sends GetReader / DocProgress to request bytes and report progress. If the main thread's WorkerTransport has not registered handlers for those messages yet, they would be lost. Ready means "main-thread routing is ready" — Article 7 deliberately places send("Ready") after setupMessageHandler(); both sides agree on this order to eliminate the race.

docParams = null releases the parameter package: evaluatorOptions and friends have already been unpacked into pdfManagerArgs, and the original object is no longer kept.

Lines 325-394: the skeleton of setupDoc

setupDoc itself is a promise chain organized around three closure callbacks:

function setupDoc(data) {
  function onSuccess(doc) {
    ensureNotTerminated();
    handler.send("GetDoc", { pdfInfo: doc });
  }
  function onFailure(ex) { /* password and error branches, see below */ }
  function pdfManagerReady() { /* loadDocument, see below */ }
 
  ensureNotTerminated();
  getPdfManager(data)
    .then(function (newPdfManager) {
      // terminated check omitted
      pdfManager = newPdfManager;
      pdfManager.requestLoadedStream(/* noFetch = */ true).then(stream => {
        handler.send("DataLoaded", { length: stream.bytes.byteLength });
      }, () => {});
    })
    .then(pdfManagerReady, onFailure);
}

Three-part division of labor: getPdfManager selects the document manager; a side branch hooks requestLoadedStream(noFetch = true) to send DataLoaded once all bytes arrive; the main chain proceeds to pdfManagerReady for document initialization. noFetch = true means range requests are not actively chased — the progressive full stream is left to fill everything naturally, echoing Article 7's conclusion that "GetDoc and DataLoaded are two independent messages".

Lines 193-302: getPdfManager — the race between two PdfManagers

With the demo's data = null, the network branch is taken:

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

PDFWorkerStream translates reader operations into GetReader / GetRangeReader messages; the byte channel itself was covered in Articles 7 and 9 and is not repeated here. The key is the two parallel paths that follow:

Path A: fullReader.headersReady.then(...)
  → isRangeSupported is true
  → new NetworkPdfManager({ source: pdfStream, length: 22491828, ... })
  → flush the early-arriving chunks from cachedChunks into it
  → resolve(newPdfManager)
 
Path B: the readData() loop
  → await fullReader.read() for each chunk of the full stream
  → newPdfManager not yet created: cachedChunks.push(value)
  → newPdfManager created: newPdfManager.sendProgressiveData(value)
  → full stream done, manager still not created (server lacks range): new LocalPdfManager(merged bytes)
DimensionPath A (NetworkPdfManager)Path B fallback (LocalPdfManager)
TriggerResponse headers support rangeFull stream finishes without range capability
Data sourcepdfStream (lazy; missing chunks requested on demand)Complete bytes in memory
ensure behaviorCatches MissingDataException → requestRange → retriesReads directly; nothing can be missing
Hit by the demoYesNo

Article 9 already verified the demo server supports range (isRangeSupported = true, contentLength = 22491828), so path A wins: as soon as the response headers arrive, NetworkPdfManager is created and getPdfManager's promise resolves immediately — without waiting for the full download. Meanwhile path B's readData loop does not stop: it keeps piping every chunk of the full stream into the ChunkedStreamManager via sendProgressiveData, so later parsing almost always hits data that is already there.

cachedChunks handles a real race: the first chunk of the full stream may arrive before the header validation finishes; it is buffered and flushed into the manager once created — not a single byte is lost.

Another detail:

pdfManagerArgs.disableAutoFetch ||= fullReader.isStreamingSupported;
// demo: false ||= true → true

Since the streaming full stream is already delivering data continuously, there is no need to issue extra "auto-fetch the neighborhood" requests when the parser hits a gap, so disableAutoFetch is raised to true. The disableAutoFetch: false parsed in Article 2 is rewritten here by a runtime condition.

Lines 115-126 (pdf_manager.js): the five steps of initDocument

pdfManagerReady calls loadDocument(false), whose first step is:

async initDocument(recoveryMode) {
  await this.ensureDoc("checkHeader");      // %PDF- magic and version
  await this.ensureDoc("parseStartXRef");   // locate startxref at the file tail
  await this.ensureDoc("parse", [recoveryMode]); // parse the xref table and trailer
  await this.ensureDoc("checkFirstPage", [recoveryMode]);
  await this.ensureDoc("checkLastPage", [recoveryMode]);
}

These five steps turn "a stretch of bytes" into "an addressable document": validate the file header, find the xref entry from the file tail, parse the cross-reference table to build the object index, then probe the first and last pages to confirm the page count is trustworthy. The demo's document is well-formed and all five steps pass in order.

For NetworkPdfManager, every ensure may hit MissingDataException, triggering requestRange to ask the main thread for the [begin, end) interval, then retrying automatically — since the xref sits at the file tail, the first range requests head straight to the end of the file, without waiting for the full stream to flow there. For the xref parsing itself, see Deep Dive 6: initDocument.

Lines 351-368: XRefParseException and recovery mode

When loadDocument(false) fails, errors are routed by type:

loadDocument(false).then(onSuccess, function (reason) {
  ensureNotTerminated();
  if (!(reason instanceof XRefParseException)) {
    onFailure(reason);
    return;
  }
  pdfManager.requestLoadedStream().then(function () {
    ensureNotTerminated();
    loadDocument(true).then(onSuccess, onFailure);
  }, onFailure);
});

Only XRefParseException earns a recovery pass: first requestLoadedStream() waits for all bytes, then the retry runs with recoveryMode = true — the parser no longer trusts the xref table and instead rescans the whole file to rebuild the object index. Any other error (password, network failure) goes straight to onFailure with no futile retry. The demo's xref is intact, so this path is not taken.

Lines 168-191: loadDocument assembles the GetDoc payload

After initialization, document-level metadata is collected:

const [numPages, fingerprints] = await Promise.all([
  pdfManager.ensureDoc("numPages"),      // 756
  pdfManager.ensureDoc("fingerprints"),  // file identifier array
]);
const htmlForXfa = isPureXfa ? ... : null; // demo is not XFA, so null
return { numPages, fingerprints, htmlForXfa };

Back in onSuccess: handler.send("GetDoc", { pdfInfo: doc }). This pure-action message crosses the thread boundary and is received by the main thread's GetDoc handler from Article 7: it writes pagesMapper.pagesNumber = 756, creates the PDFDocumentProxy, and resolves loadingTask._capability. The demo's await loadingTask.promise resumes right here — joining end to end with the return-value section of Article 2.

Not taken by the demo: the password loop

If the document is encrypted, loadDocument throws PasswordException and enters the password branch of onFailure:

const res = await handler.sendWithPromise("PasswordRequest", ex);
// once the main thread's loadingTask.onPassword supplies a password:
pdfManager.updatePassword(password);
pdfManagerReady(); // retry the whole load

The worker keeps no retry counter; a wrong password simply triggers another PasswordRequest, forming a loop until the user supplies the right one or the main thread rejects. The demo's document is not encrypted (password = null), so this branch is not taken.

Lifecycle: Cleanup and Terminate

The two lifecycle messages at the end of the registry decide how this document winds down on the worker side:

handler.on("Cleanup", function () {
  return pdfManager.cleanup(/* manuallyTriggered = */ true);
});
 
handler.on("Terminate", async function () {
  terminated = true;
  // pdfManager.terminate → cancelXHRs → terminate all WorkerTasks → handler.destroy()
});

Cleanup only purges caches (fonts, images, etc.) and the document remains usable; Terminate is final: it first sets terminated to seal every entry point, then aborts network requests, walks WorkerTasks terminating each one, and finally handler.destroy() tears down the d0_worker ↔ d0 channel itself. The demo never destroys the document, so neither fires; they are the other half of the main thread's loadingTask.destroy().

Summary

Following the demo into the worker thread, this article walked through the two events left open in Article 2:

  1. On GetDocRequest, createDocumentHandler first checks apiVersion against the build version, then verifies the prototype chains are unpolluted;
  2. It creates the document-level d0_worker ↔ d0 channel mirroring the main thread, and establishes closure state such as pdfManager / terminated / WorkerTasks;
  3. After synchronously registering 20+ business handlers it returns 'd0_worker', which the main thread receives as workerId — with not a single PDF byte read yet;
  4. Only on Ready does setupDoc run: getPdfManager races between "headers support range → NetworkPdfManager" and "full stream complete → LocalPdfManager"; the demo hits the former, with cachedChunks catching early arrivals;
  5. initDocument's five steps build the object index, loadDocument collects numPages = 756 and fingerprints, and onSuccess sends GetDoc back;
  6. The side-branch requestLoadedStream(noFetch = true) sends DataLoaded once all bytes arrive; wrong passwords loop through PasswordRequest, and a broken xref falls back to recovery mode;
  7. Cleanup / Terminate govern cache cleanup and final teardown of the document on the worker side.

At this point, both threads of getDocument have completed their readiness declarations and the document-loading loop is closed. The demo's next step, pdf.getPage(1), is the first to actually use the GetPage handler registered here — back to Article 3.

StreamPDF's design principle: anything that can be preprocessed is never left to parse time — the server solves every performance problem once, and the browser only fetches and renders the data it must.

More from PDF.js Deep Dive from Demo