PDF.js Deep Dive from Demo 13: createDocumentHandler and setupDoc after Ready
October 29, 2026 · StreamPDF Team

Introduction
The end of the handshake in Article 2 left two "click to view the event" hooks:
- The worker receives
GetDocRequestand replies withworkerId; - 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:
- What does the worker do upon
GetDocRequest, and why is the reply exactly'd0_worker'? - What are the 20+ message handlers registered by
createDocumentHandler? - After
Ready, how doessetupDocpick aPdfManager, initialize the document, and sendGetDocback?
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.mjsandpdf.worker.mjsare 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, rejectingloadingTask.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 variable | Initial value | Responsibility |
|---|---|---|
pdfManager | undefined | The document manager, assigned only in setupDoc |
terminated | false | Set to true upon Terminate; every entry point checks it first |
cancelXHRs | null | Cancels in-flight data requests; assigned in getPdfManager |
WorkerTasks | new Set() | Registry of terminatable task units (GetOperatorList, etc.) |
verbosity | 1 | The 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:
| Group | Messages | Worker-side duty |
|---|---|---|
| Page metadata | GetPage, GetPageIndex, GetAnnotations, GetStructTree | Parse page objects, return metadata or annotations |
| Catalog queries | GetOutline, GetDestinations, GetMetadata, GetPageLayout, GetPermissions, etc. | Mostly one-line ensureCatalog(prop) forwards |
| Document data | GetData, SaveDocument, ExtractPages | Full bytes, incremental save, page extraction |
| Streaming evaluation | GetOperatorList, GetTextContent | Parse and push into the sink as chunks (form three in Article 6) |
| Font fallback | FontFallback | Fallback when a glyph fails to load on the main thread (Article 12) |
| Lifecycle | Cleanup, Terminate, Ready | Cache 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:
pdfManageris stillundefinedat 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 thatloadingTask.promisehas resolved — by thensetupDochas long assignedpdfManager. 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
Readyinstead of starting as soon asGetDocRequestarrives? A: Because the moment loading starts, the worker sendsGetReader/DocProgressto request bytes and report progress. If the main thread'sWorkerTransporthas not registered handlers for those messages yet, they would be lost.Readymeans "main-thread routing is ready" — Article 7 deliberately placessend("Ready")aftersetupMessageHandler(); 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)| Dimension | Path A (NetworkPdfManager) | Path B fallback (LocalPdfManager) |
|---|---|---|
| Trigger | Response headers support range | Full stream finishes without range capability |
| Data source | pdfStream (lazy; missing chunks requested on demand) | Complete bytes in memory |
ensure behavior | Catches MissingDataException → requestRange → retries | Reads directly; nothing can be missing |
| Hit by the demo | Yes | No |
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 → trueSince 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 loadThe 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:
- On
GetDocRequest,createDocumentHandlerfirst checksapiVersionagainst the build version, then verifies the prototype chains are unpolluted; - It creates the document-level
d0_worker ↔ d0channel mirroring the main thread, and establishes closure state such aspdfManager/terminated/WorkerTasks; - After synchronously registering 20+ business handlers it returns
'd0_worker', which the main thread receives asworkerId— with not a single PDF byte read yet; - Only on
ReadydoessetupDocrun:getPdfManagerraces between "headers support range →NetworkPdfManager" and "full stream complete →LocalPdfManager"; the demo hits the former, withcachedChunkscatching early arrivals; initDocument's five steps build the object index,loadDocumentcollectsnumPages = 756andfingerprints, andonSuccesssendsGetDocback;- The side-branch
requestLoadedStream(noFetch = true)sendsDataLoadedonce all bytes arrive; wrong passwords loop throughPasswordRequest, and a broken xref falls back to recovery mode; Cleanup/Terminategovern 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
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 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 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList RenderingOctober 27, 2026
- PDF.js Deep Dive from Demo 11: GetOperatorList and Where the 84 Operators Come FromOctober 27, 2026
- PDF.js Deep Dive from Demo 12: Font Translation, from FontFile3 to FontFaceOctober 28, 2026