PDF.js Source Walkthrough: api
October 27, 2026 · StreamPDF Team

Introduction
This is the installment of the PDF.js source walkthrough series covering the "display-layer entry point": src/display/api.js (about 3560 lines). Nearly every name a user gets from import * as pdfjsLib from "pdf.mjs" — getDocument, PDFDocumentProxy, PDFPageProxy, PDFWorker, RenderTask — is defined in this single file.
The "demo deep-dive" series has already walked the instantiation paths of these methods along the call chain: article 2 followed getDocument's parameter expansion and handshake, article 3 followed getPage, and article 5 followed render. This article takes a different angle: instead of tracing a single call, we take the entire file apart structurally, answering "what parts make up this file, what is each part responsible for, and how do they interlock."
api.js's job in one sentence: it is the main thread's facade layer — upward it offers users a clean set of proxy classes and task handles; downward it translates every method call into MessageHandler messages sent to the worker, and deposits the data the worker pushes back into the right caches and callbacks.
The export table at the end of the file is the table of contents for this article:
export {
build,
getDocument,
PDFDataRangeTransport,
PDFDocumentLoadingTask,
PDFDocumentProxy,
PDFPageProxy,
PDFWorker,
RenderTask,
version,
};Of the nine names, build and version are constant strings injected at build time; the remaining seven are the runtime API.
Module Overview
api.js can be cut into eight blocks:
| Block | Approx. lines | Exported | Responsibility |
|---|---|---|---|
getDocument | ~240 | Yes | Parameter parsing, worker startup, handshake, network stream selection |
PDFDocumentLoadingTask | ~110 | Yes | User handle for the loading process: promise / onProgress / destroy |
PDFDataRangeTransport | ~60 | Yes | Abstract base class for custom range loading (subclassed by users) |
PDFDocumentProxy | ~450 | Yes | Main-thread proxy for the worker-side PDFDocument |
PDFPageProxy | ~720 | Yes | Main-thread proxy for the worker-side PDFPage, rendering state machine |
PDFWorker | ~330 | Yes | Worker creation, handshake, fake-worker fallback |
WorkerTransport | ~840 | No | Message routing hub, page and object caches |
RenderTask / InternalRenderTask | ~280 | Yes / No | User handle for a render task and the scheduling loop |
Ownership relations among the eight blocks:
| Owner | Owns | Relationship |
|---|---|---|
getDocument | All of the above | Wires the seven classes together, returns task synchronously |
PDFDocumentLoadingTask | _transport, _worker | Hands the proxy to the user once loading completes |
PDFDocumentProxy | _transport | Every method forwards to the transport |
WorkerTransport | messageHandler, #pageCache, three factories | Each method = one message or a group of messages |
PDFPageProxy | _transport, _intentStates | Rendering state bucketed by cacheKey |
RenderTask | _internalRenderTask | User-visible handle wrapping the internal task |
InternalRenderTask | gfx (CanvasGraphics) | Actually drives canvas painting |
getDocument: The One-Shot Expansion Entry Point
All of getDocument's work falls into five stages: create the task, expand the parameters, start the worker, assemble the parameter bundles, and attach the handshake callback. Article 2 of the demo series already walked through this with concrete values; here we keep only structural observations.
Parameter Expansion: Read src Exactly Once
At the top of the function, src is expanded in one shot into 35+ local variables, and src is never read again afterwards:
function getDocument(src = {}) {
const task = new PDFDocumentLoadingTask();
const { docId } = task;
const url = src.url ? getUrlProp(src.url) : null;
const data = src.data ? getDataProp(src.data) : null;
// ... 35+ local variables
const useWorkerFetch =
typeof src.useWorkerFetch === "boolean"
? src.useWorkerFetch
: (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) ||
!!(
BinaryDataFactory === DOMBinaryDataFactory &&
cMapUrl && cMapPacked && standardFontDataUrl && wasmUrl &&
isValidFetchUrl(cMapUrl, document.baseURI) &&
// ...
);Each parameter resolves within three lines, but the patterns vary: some normalize the type (getUrlProp turns a string into a URL), some flip the semantics (ignoreErrors = src.stopAtErrors !== true), some cascade off variables just computed (useSystemFonts depends on disableFontFace), and some are compound conditions (useWorkerFetch). The full rule set for this segment is covered in article 2 of the demo series and is not repeated here.
Design note: the entry function concentrates the conversion from "user input" to "internal state" in a single code block. The following 4000 lines see only clean local variables and parameter bundles, and never again have to ask "did the user actually pass this?" This is the universal practice across the whole PDF.js library — validation converges at the boundary.
Two Parameter Bundles: Cross-Thread and Main-Thread-Only
Once parsed, the parameters are packed into two bundles:
const docParams = {
docId,
apiVersion: /* BUNDLE_VERSION */,
data,
password,
disableAutoFetch,
rangeChunkSize,
docBaseUrl,
enableXfa,
evaluatorOptions: {
maxImageSize, disableFontFace, ignoreErrors,
isOffscreenCanvasSupported, isImageDecoderSupported,
canvasMaxAreaInBytes, fontExtraProperties, useSystemFonts,
useWasm, useWorkerFetch, cMapUrl, cMapPacked, iccUrl,
standardFontDataUrl, wasmUrl,
hasGPU: false, // placeholder, backfilled once the worker is ready
},
};
const transportParams = {
ownerDocument,
pdfBug,
styleElement,
enableHWA,
loadingParams: { disableAutoFetch, enableXfa },
};docParams is sent to the worker via postMessage, so every value in it must be serializable; transportParams stays on the main thread, so it can hold DOM objects like ownerDocument. evaluatorOptions.hasGPU starts as a false placeholder and is backfilled with the real value once both gpuPromise and the worker handshake complete — GPU probing and worker startup run in parallel, neither waiting for the other.
Three Branches of Network Streams
The three parameters data, range, and url are mutually exclusive, with priority matching the code order:
let networkStream;
if (data) {
// the whole PDF is already in memory; no network stream needed
} else if (rangeTransport) {
networkStream = new PDFDataTransportStream({
pdfDataRangeTransport: rangeTransport, disableRange, disableStream,
});
} else if (url) {
const NetworkStream = getNetworkStream(url); // prefer fetch, fall back to XHR
networkStream = new NetworkStream({
url, httpHeaders, withCredentials, rangeChunkSize, disableRange, disableStream,
});
} else {
throw new Error("getDocument - expected either `data`, `range`, or `url` parameter.");
}getNetworkStream picks an implementation per environment: http(s) goes through PDFFetchStream, and older environments without support fall back to PDFNetworkStream (XHR). The data branch doesn't even build a stream — the bytes are already complete, and the worker takes ownership of data.buffer via the GetDocRequest transfer; the main thread's copy becomes invalid immediately.
Handshake Timing and the Race
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
);
// ... build networkStream
return workerIdPromise.then(workerId => {
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);
});
})
.catch(task._capability.reject)
.finally(task._setupCapability.resolve);
return task;Three things to note:
taskis returned synchronously; the whole.thenchain advances in the background. Any error at any step of the chain rejectstask._capability— the very promise the user awaits viaawait loadingTask.promise._setupCapabilityresolves whether the chain succeeds or fails. It does not report a result to the user; it only tellsdestroy()that "_transportis either ready now or never will be" — this is the synchronization point for the race betweendestroyand the handshake, detailed below.- After assigning
task._transport,task.destroyedis checked once more: the user may have calleddestroy()during the handshake, in which case throwing routes the chain into.catch, and orderly shutdown is left to the transport.
PDFDocumentLoadingTask: The User Handle for Loading
class PDFDocumentLoadingTask {
static #docId = 0;
_capability = Promise.withResolvers();
_setupCapability = Promise.withResolvers();
_transport = null;
_worker = null;
docId = `d${PDFDocumentLoadingTask.#docId++}`;
destroyed = false;
onPassword = null;
onProgress = null;
get promise() {
return this._capability.promise;
}
}docId comes from a static counter; the first document is d1. This id appears in the MessageHandler source/target names (new MessageHandler(docId, workerId, ...)), and when multiple documents coexist it distinguishes which document a message belongs to.
The two capabilities have a clear division of labor:
| Field | Who resolves/rejects | Who waits | Semantics |
|---|---|---|---|
_capability | The transport's GetDoc / DocException handlers | The user (task.promise) | Document ready or loading failed |
_setupCapability | getDocument's .finally | destroy() | The startup chain has finished (success or failure) |
destroy's Race Handling
destroy() is the most deliberate method in this small class; the four steps must stay in order:
async destroy() {
this.destroyed = true;
// the startup chain will later reject _capability with "Loading aborted"; claim
// that rejection up front so it isn't treated as an unhandled promise rejection
this._capability.promise.catch(() => {});
try {
// must be set synchronously: a later PDFWorker.create() on the shared
// workerPort must see it immediately and throw
if (this._worker?.port) this._worker._pendingDestroy = true;
// wait for the startup chain to finish, guaranteeing _transport is set so
// the "Terminate" message can actually be sent
await this._setupCapability.promise;
await this._transport?.destroy();
} catch (ex) {
if (this._worker?.port) delete this._worker._pendingDestroy;
throw ex;
}
this._transport = null;
this._worker?.destroy();
this._worker = null;
}The synchronous setting of _pendingDestroy corresponds to issue 16777: if the flag were set after the await, another getDocument in the event-loop gap could reuse the same workerPort and get a worker that is being destroyed. On failure the flag is rolled back before rethrowing — if the worker wasn't destroyed, the "being destroyed" seal must not be left behind.
PDFDataRangeTransport: A Push-Mode Range Abstraction
When users want to control range requests themselves (e.g. a custom protocol, or adding authentication), they subclass this abstract class:
class PDFDataRangeTransport {
#capability = Promise.withResolvers();
#listener = null;
constructor(length, initialData, progressiveDone = false, contentDispositionFilename = null) { ... }
onDataRange(begin, chunk) {
this.#listener({ type: "range", begin, chunk });
}
onDataProgressiveRead(chunk) {
this.#capability.promise.then(() => {
this.#listener({ type: "progressiveRead", chunk });
});
}
transportReady(listener) {
this.#listener = listener;
this.#capability.resolve();
}
requestDataRange(begin, end) {
unreachable("Abstract method PDFDataRangeTransport.requestDataRange");
}
abort() {}
}Note the asymmetry between the two kinds of entry points:
onDataRangeforwards directly to the listener. Range data is actively pulled by the transport viarequestDataRange; if data can be pulled, the listener was registered long ago;onDataProgressiveRead/onDataProgressiveDonemust first wait on#capability. Progressive data is pushed as soon as the user's network is ready, which can be earlier than thetransportReadycall; without queueing, that data would land on#listener === null.
Design note: for the same "data has arrived" event, pull mode is naturally ordered, while push mode must queue via a promise. Keeping the queueing logic in the base class lets subclasses just call when they have data, without caring about timing.
PDFDocumentProxy: Thirty Forwarding Methods
PDFDocumentProxy is the proxy for the worker-side PDFDocument; its constructor stores only two fields:
class PDFDocumentProxy {
constructor(pdfInfo, transport) {
this._pdfInfo = pdfInfo;
this._transport = transport;
// TESTING / INTERNAL_VIEWER builds additionally attach getRawData etc. dynamically
}
}Then come about 30 methods, the vast majority one-line forwarders: getPage, getOutline, getMetadata, getAttachments, getJSActions... all delegate to the same-named method on this._transport. Only four categories deserve individual treatment.
Synchronous Getters: Laying Out Already-Ready Data
Getters like numPages, fingerprints, annotationStorage, and canvasFactory send no messages — the data already arrived on the main thread with pdfInfo during the GetDoc handshake. isPureXfa uses the shadow utility:
get isPureXfa() {
return shadow(this, "isPureXfa", !!this._transport._htmlForXfa);
}shadow computes the value on first read, then replaces it with a plain data property via Object.defineProperty, so every subsequent access is a plain field read, never going through the getter again.
Test-Only Dynamic Methods
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
Object.defineProperty(this, "getNetworkStreamName", {
value: () => this._transport.getNetworkStreamName(),
});
// getXFADatasets / getStartXRefPos / getAnnotArray work the same way
}The preprocessor of production builds deletes the entire if block — PDFJSDev.test("TESTING") is the constant false in release packages, so these methods never appear on instances. All of pdf.js's diagnostic capabilities go through this switch, keeping the production API surface unpolluted.
getOptionalContentConfig's Intent Conversion
getOptionalContentConfig({ intent = "display" } = {}) {
const { renderingIntent } = this._transport.getRenderingIntent(intent);
return this._transport.getOptionalContentConfig(renderingIntent);
}The string intent is first converted into bit flags by getRenderingIntent, then sent to the worker with the request. This renderingIntent is later bitwise-AND-verified against the one in render() — when the two intents disagree, render throws, preventing "a config fetched for display being used for print rendering."
cleanup's XFA Special Case
cleanup(keepLoadedFonts = false) {
return this._transport.startCleanup(keepLoadedFonts || this.isPureXfa);
}Pure XFA documents forcibly keep loaded fonts. XFA forms go down the HTML rendering path, with fonts attached to the document via the DOM's @font-face; clearing the fonts and re-rendering would fall the whole page back to system fonts, so when isPureXfa holds, keepLoadedFonts = true no matter what.
PDFPageProxy: A Rendering State Machine Bucketed by intentState
PDFPageProxy is the state-heaviest class in the whole file. Besides the page number and page info, its constructor sets up two levels of object caches:
constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
this._pageIndex = pageIndex;
this._pageInfo = pageInfo;
this._transport = transport;
/** @type {PDFObjects} */
this.commonObjs = transport.commonObjs; // document-level shared: fonts, cross-page images
this.objs = new PDFObjects(); // page-level private: this page's images and patterns
this._intentStates = new Map();
// ...
}The division between commonObjs and objs corresponds to the two messages by which the worker pushes data: commonobj (document level) and obj (page level), which we'll see in the WorkerTransport section below.
_intentStates: Bucketed by cacheKey
The same page may be rendered concurrently multiple times: one for display, one for print, one for editing mode. Each has its own operatorList, renderTasks set, and ready capability, and PDF.js buckets them by cacheKey:
const intentState = this._intentStates.getOrInsertComputed(cacheKey, makeObj);cacheKey is composed by WorkerTransport.getRenderingIntent (intent bit flags + annotation storage hash), detailed below. Both render() and getOperatorList() compute the cacheKey first, and calls with the same key share the same operatorList — rendering the same page twice with the same intent means the worker parses it only once.
render(): Pump the Stream Immediately, Start Painting Once Ready
render() executes in two stages. The first stage completes synchronously:
render({ canvasContext, canvas = canvasContext.canvas, viewport, intent = "display", ... }) {
const intentArgs = this._transport.getRenderingIntent(intent, annotationMode, ...);
const { renderingIntent, cacheKey } = intentArgs;
this.#pendingCleanup = false; // a render is running; cancel the pending cleanup
optionalContentConfigPromise ||=
this._transport.getOptionalContentConfig(renderingIntent);
const intentState = this._intentStates.getOrInsertComputed(cacheKey, makeObj);
if (intentState.streamReaderCancelTimeout) {
clearTimeout(intentState.streamReaderCancelTimeout); // cancel the delayed abort
intentState.streamReaderCancelTimeout = null;
}
if (!intentState.displayReadyCapability) {
// first render: build the operatorList container and start pumping from the worker immediately
intentState.displayReadyCapability = Promise.withResolvers();
intentState.operatorList = { fnArray: [], argsArray: [], lastChunk: false, separateAnnots: null };
this._pumpOperatorList(intentArgs);
}
// ... build InternalRenderTask and register it into intentState.renderTasksThe second stage happens asynchronously: once both "operatorList first chunk ready" and "optional content config" are in place, the canvas is actually initialized:
Promise.all([
intentState.displayReadyCapability.promise,
optionalContentConfigPromise,
]).then(([transparency, optionalContentConfig]) => {
if (this.destroyed) { complete(); return; }
if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
throw new Error("Must use the same `intent`-argument ...");
}
internalRenderTask.initializeGraphics({ transparency, optionalContentConfig });
internalRenderTask.operatorListChanged();
}).catch(complete);
return renderTask; // returned synchronously, waits on no promise
}Two design decisions here are worth remembering:
- Stream fetching waits for no one.
_pumpOperatorListstarts synchronously insiderender(), without waiting for the canvas to be ready. Worker-side parsing and main-thread canvas preparation run in parallel; when the first operatorList chunk arrives, theStartRenderPagemessage resolvesdisplayReadyCapability, and by then the canvas has usually long been initialized. - Intent is verified bitwise. If
optionalContentConfig.renderingIntent & renderingIntentis zero, the intents of the two calls don't match (e.g. the config was fetched withprintwhile rendering usesdisplay), and it throws outright rather than rendering a page with missing layers.
_pumpOperatorList: Pumping a Streaming Interface into an Array
The operatorList crosses threads via sendWithStream — the worker pushes each chunk as soon as it parses it:
_pumpOperatorList({ renderingIntent, cacheKey, annotationStorageSerializable, modifiedIds }) {
const { map, transfer } = annotationStorageSerializable;
const readableStream = this._transport.messageHandler.sendWithStream(
"GetOperatorList",
{
pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
pageIndex: this._pageIndex,
intent: renderingIntent,
cacheKey,
annotationStorage: map,
modifiedIds,
},
/* queueingStrategy = */ undefined,
transfer
);
const reader = readableStream.getReader();
const intentState = this._intentStates.get(cacheKey);
intentState.streamReader = reader;
const pump = () => {
reader.read().then(({ value, done }) => {
if (done) { intentState.streamReader = null; return; }
if (this._transport.destroyed) return;
this._renderPageChunk(value, intentState); // append into fnArray/argsArray and wake the render tasks
pump();
}, reason => { /* on error: mark lastChunk, then wake and reject each capability */ });
};
pump();
}The line pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1 deserves expansion: _pageIndex + 1 is the page number in the user's eyes, getPageId maps it to the source document page number, and - 1 converts back to a zero-based index. When pages have been reordered/deleted/duplicated (pagesMapper's job), user page numbers and worker page numbers diverge, and every cross-thread message must go through this translation layer.
_abortOperatorList: The 100ms Delayed Cancellation
When rendering is cancelled (e.g. the user zooms rapidly), the main thread does not immediately tell the worker to stop parsing:
if (reason instanceof RenderingCancelledException) {
let delay = RENDERING_CANCELLED_TIMEOUT; // 100ms
if (reason.extraDelay > 0 && reason.extraDelay < 1000) {
delay += reason.extraDelay;
}
intentState.streamReaderCancelTimeout = setTimeout(() => {
intentState.streamReaderCancelTimeout = null;
this._abortOperatorList({ intentState, reason, force: true });
}, delay);
return;
}The comment explains why: in the viewer, after zooming/rotating, the same page is re-render()ed immediately with new parameters, and cacheKey contains intent but not scale — the new render reuses the same stream. If a cancellation cut off the worker's parsing at once, the re-render would have to parse again from scratch. Leaving a 100ms window lets a new render() come in and clearTimeout the delayed task, saving the stream. Only if nobody reuses it within the window does force: true really cancel.
Another layer of protection comes before that:
if (intentState.renderTasks.size > 0) return;When other render tasks are still attached to the same intentState, a single task's error does not cut the stream — one failed render() must not break other renders of the same page.
cleanup: Four Conditions, All Required
Page-level cleanup is not "clean on demand":
#tryCleanup() {
if (!this.#pendingCleanup || this.destroyed) return false;
for (const { renderTasks, operatorList } of this._intentStates.values()) {
if (renderTasks.size > 0 || !operatorList.lastChunk) return false;
}
this._intentStates.clear();
this.objs.clear();
this.#pendingCleanup = false;
return true;
}All must hold simultaneously: someone has requested cleanup (#pendingCleanup), the page is not destroyed, all intentStates have no running render tasks, and all operatorLists have received their final chunk. In render()'s completion callback, print intent actively sets #pendingCleanup = true and attempts cleanup — printing easily spans dozens of pages, and releasing glyph/image data right after printing is the only way to keep memory down.
PDFWorker: Creation, Handshake, and Fallback
PDFWorker encapsulates the "parsing thread" resource. Its static block settles two things at class-load time (GENERIC build):
static {
if (isNodeJS) {
// Node.js doesn't support the Worker usage here; disable it outright
this.#isWorkerDisabled = true;
GlobalWorkerOptions.workerSrc ||= PDFJSDev.test("LIB")
? "../pdf.worker.js"
: "./pdf.worker.mjs";
}
this._isSameOrigin = (baseUrl, otherUrl) => { /* returns false for non-same-origin or non-http */ };
this._createCDNWrapper = url => {
const wrapper = `await import("${url}");`;
return URL.createObjectURL(new Blob([wrapper], { type: "text/javascript" }));
};
}_createCDNWrapper solves a frequent problem in real deployments: when workerSrc points at a CDN (cross-origin), new Worker(url) is rejected by the browser. Wrapping it in a same-origin blob lets the import() inside the blob fetch the CDN code — import is not subject to the worker script's same-origin restriction.
The test Handshake: Verifying transfer Really Works
After #initialize builds the Worker and MessageHandler, it doesn't declare readiness right away; it first sends a probe message:
const sendTest = () => {
const testObj = new Uint8Array();
// verify postMessage's transfer capability: the Uint8Array must still be a Uint8Array on the worker side
messageHandler.send("test", testObj, [testObj.buffer]);
};
sendTest(); // sent immediately once
// sent once more when the worker's "ready" message arrives; the worker only handles whichever arrives firstOnly when the worker replies with truthy data are #messageHandler / #port / #webWorker attached to the instance and the promise resolved. A falsy reply or an error routes into terminateEarly to clean up and fall back to a fake worker. The double send covers timing: when the worker loads fast, the immediate test arrives first; when slow, the one triggered by ready acts as a backstop.
The Fake-Worker Fallback Chain
When a real worker is unavailable (Node environment, constructor throws, test fails), the fallback is a "fake worker" — loading the worker code into the main thread:
#setupFakeWorker() {
if (!PDFWorker.#isWorkerDisabled) {
warn("Setting up fake worker.");
PDFWorker.#isWorkerDisabled = true; // later documents won't even try a real worker
}
PDFWorker._setupFakeWorkerGlobal.then(WorkerMessageHandler => {
if (this.destroyed) { /* reject */ return; }
const port = new LoopbackPort();
const id = `fake${PDFWorker.#fakeWorkerId++}`;
const workerHandler = new MessageHandler(id + "_worker", id, port);
WorkerMessageHandler.setup(workerHandler, port);
this.#messageHandler = new MessageHandler(id, id + "_worker", port);
this.#resolve();
});
}LoopbackPort is a pair of interconnected local ports whose messages are dispatched synchronously rather than via postMessage. To the upper-layer WorkerTransport, the interface is identical — it cannot tell whether the other end is a thread or a loopback. _setupFakeWorkerGlobal caches the loading promise with shadow, so the worker code is dynamically imported only once per library.
create: Port Reuse and the Destruction Seal
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);
}#workerPorts is a WeakMap caching PDFWorkers keyed by workerPort: multiple create calls passing the same port share the same worker. Hitting the _pendingDestroy seal (the one set synchronously by destroy() above) throws directly, and the error message tells you exactly to await the destruction — this is the fix for issue 16777.
WorkerTransport: The Message Routing Hub
WorkerTransport is not exported, yet it is the real main-thread hub: document-level caches, page caches, and the message routing table all live on it.
Three Sets of Page Caches
#pageCache = new Map(); // pageIndex -> PDFPageProxy
#pagePromises = new Map(); // pageIndex -> Promise<PDFPageProxy>
#pageRefCache = new Map(); // "12R0" -> pageIndexgetPage checks #pagePromises first; concurrent calls for the same page share one promise, and the worker receives only one GetPage message. Note the two index directions: pageIndex = pageNumber - 1 is the user-view zero-based index (the cache key), while newPageIndex = pagesMapper.getPageId(pageNumber) - 1 is the source-document index sent to the worker. Once pages are reordered the two diverge: caches are keyed by user view, messages are sent by source view.
#pageRefCache serves reverse lookups: cachedPageNumber(ref) maps a reference string like "12R0" (the suffix omitted when gen is 0) back to a page number, a pure local hit that sends no message.
#cacheSimpleMethod: Local Caching for Idempotent Requests
#cacheSimpleMethod(name, data = null) {
return this.#methodPromises.getOrInsertComputed(name, () =>
this.messageHandler.sendWithPromise(name, data)
);
}Requests whose results don't change between calls — GetFieldObjects, GetDocJSActions, GetOptionalContentConfig, GetMetadata — go through it: the first call sends a message, later calls return the cached promise. The counter-example is getSignatureData — signature bytes are sizable, and caching them in a Map would keep them alive as long as the document; the comment explicitly says "not cached, consumed once."
getRenderingIntent: Bit Flags and cacheKey
render, getOperatorList, getAnnotations, and getOptionalContentConfig all compile their string parameters into bit flags through it first:
let renderingIntent = RenderingIntentFlag.DISPLAY;
switch (intent) {
case "any": renderingIntent = RenderingIntentFlag.ANY; break;
case "print": renderingIntent = RenderingIntentFlag.PRINT; break;
}
switch (annotationMode) {
case AnnotationMode.DISABLE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE; break;
case AnnotationMode.ENABLE_FORMS: renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS; break;
case AnnotationMode.ENABLE_STORAGE:
renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
annotationStorageSerializable = annotationStorage.serializable; // serialized form data travels with the message
break;
}
if (isEditing) renderingIntent += RenderingIntentFlag.IS_EDITING;
if (isOpList) renderingIntent += RenderingIntentFlag.OPLIST;
const { ids: modifiedIds, hash: modifiedIdsHash } = annotationStorage.modifiedIds;
const cacheKeyBuf = [renderingIntent, annotationStorageSerializable.hash, modifiedIdsHash];
return { renderingIntent, cacheKey: cacheKeyBuf.join("_"), ... };cacheKey compresses "rendering intent + form snapshot + modification set" into one string — when form content changes, the key changes, and _intentStates naturally starts a new bucket and re-parses; when forms are unchanged, renders with the same intent share the stream. When printing with a PrintAnnotationStorage passed in, the print-specific storage is used instead, so print and display edits don't interfere with each other.
setupMessageHandler: Thirteen Message Routes
At the end of the constructor, setupMessageHandler() registers all handlers for the worker→main-thread direction. Grouped by function:
Three data-channel routes
| Message | Handler essentials |
|---|---|
GetReader | Takes networkStream.getFullReader(); in sink.onPull, reads a chunk and enqueues a chunk, with the buffer transferred out |
ReaderHeadersReady | Replies {isStreamingSupported, isRangeSupported, contentLength}; when both are supported, detaches the fullReader's onProgress |
GetRangeReader | Fetches a range reader for [begin, end); if unavailable (data already in the full stream), closes with sink.close() directly instead of re-requesting |
The "close if unavailable" branch in GetRangeReader is fully commented: during streaming loads, data may already have been pulled down by the fullReader but not yet postMessaged to the worker, so issuing another range request would be waste; the worker just waits for the full stream.
Four document-lifecycle routes
| Message | Handler essentials |
|---|---|
GetDoc | Sets pagesMapper.pagesNumber, extracts htmlForXfa, resolves loadingTask._capability with a PDFDocumentProxy |
DocException | Rejects the same capability after wrapReason(ex) |
PasswordRequest | Creates #passwordCapability, calls the user's onPassword(updatePassword, code), and returns the callback promise to the worker |
DataLoaded | Emits one final 100% onProgress and resolves downloadInfoCapability |
PasswordRequest translates "the worker blocks waiting for a password" into "the main thread waits for a user callback": if updatePassword receives an Error it rejects (give up opening); a string resolves {password} and lets the worker retry decryption.
Two object-delivery routes
commonobj (document level) dispatches into five paths by type:
messageHandler.on("commonobj", ([id, type, exportedData]) => {
if (this.commonObjs.has(id)) return null; // dedup
switch (type) {
case "Font": {
const font = new FontFaceObject(new FontInfo(exportedData), inspectFont, ...);
this.fontLoader.bind(font)
.catch(() => messageHandler.sendWithPromise("FontFallback", { id }))
.finally(() => {
if (!font.fontExtraProperties) font.clearData(); // release font bytes as soon as it's attached to the DOM
this.commonObjs.resolve(id, font);
});
break;
}
case "CopyLocalImage": /* cross-page image reuse: find the data with the same ref in the page cache and structuredClone a copy */ break;
case "FontPath": this.commonObjs.resolve(id, new FontPathInfo(exportedData)); break;
case "Image": this.commonObjs.resolve(id, exportedData); break;
case "Pattern": this.commonObjs.resolve(id, new PatternInfo(exportedData).getIR()); break;
}
return null;
});The font path is the most elaborate: when bind fails (the system refuses to load the font), it isn't condemned outright — a FontFallback message lets the worker fall back to built-in rendering; on success, clearData immediately releases the possibly multi-MB font bytes instead of waiting for document cleanup.
obj (page level) only accepts Image / Pattern, with one extra line of defense:
if (pageProxy._intentStates.size === 0) {
imageData?.bitmap?.close(); // page already cleaned up; release the ImageBitmap directly
return;
}This is the fix for bug 1854145: data the worker pushes after a successful page cleanup must not enter the store (PDFObjects has been cleared, so nothing would release it); closing the bitmap directly prevents the leak.
The rest: StartRenderPage wakes the page object's displayReadyCapability; DocProgress forwards loading progress; FetchBinaryData (non-MOZCENTRAL) has binaryDataFactory download CMaps/fonts/wasm on the main thread — exactly the main-thread download path when useWorkerFetch = false in getDocument.
saveDocument and extractPages: Serialization and Remapping
Saving a document brings the main thread's form edits back to the worker:
saveDocument() {
const { map, transfer } = this.annotationStorage.serializable;
return this.messageHandler.sendWithPromise("SaveDocument", {
isPureXfa: !!this._htmlForXfa,
numPages: this._numPages,
annotationStorage: map,
filename: this.#fullReader?.filename ?? null,
}, transfer).finally(() => {
this.annotationStorage.resetModified(); // clear the "modified" flag whether it succeeds or fails
});
}extractPages is more complex: after pages have been reordered, the pageIndex recorded in annotations is user-view, while the worker looks up by source view, so it must be translated via pagesMapper.getMapping(); when the same source page is extracted into multiple copies, copyLevels further marks which copy each editor annotation belongs to:
const copyLevel = copyLevels?.[v.pageIndex] ?? 0;
const sourceIdx = mapping[v.pageIndex] - 1;
if (sourceIdx !== v.pageIndex || copyLevel !== 0) {
remapped.set(k, { ...v, pageIndex: sourceIdx, copyLevel });
continue;
}A -1 in copyLevels means the page was not extracted — the corresponding entries are still kept, because extracted stamp annotations may reference their bitmapId; seeing a negative value, the worker skips the annotation itself but keeps the bitmap association.
destroy: Stop the Pages First, Then the Worker
destroy() {
if (this.destroyCapability) return this.destroyCapability.promise; // idempotent
this.destroyed = true;
this.destroyCapability = Promise.withResolvers();
this.#passwordCapability?.reject(new Error("Worker was destroyed during onPassword callback"));
const waitOn = [];
for (const page of this.#pageCache.values()) waitOn.push(page._destroy()); // wait for all rendering to settle
// ... clear caches, resetModified
const terminated = this.messageHandler.sendWithPromise("Terminate", null); // wait for the worker's long task to finish
waitOn.push(terminated);
Promise.all(waitOn).then(() => {
this.commonObjs.clear();
this.fontLoader.clear();
this.#methodPromises.clear();
this.filterFactory.destroy();
TextLayer.cleanup();
this.#networkStream?.cancelAllRequests(new AbortException("Worker was terminated."));
this.messageHandler?.destroy();
this.messageHandler = null;
this.destroyCapability.resolve();
}, this.destroyCapability.reject);
return this.destroyCapability.promise;
}The order is "pages → worker → local resources": first let all RenderTasks settle (rAF callbacks may still be queued), then send Terminate and wait for the worker to wrap up, and finally clear the main thread's fonts, filters, text layer, and network requests. A document blocked on onPassword gets rejected immediately instead of hanging forever.
RenderTask and InternalRenderTask: Handle and Scheduler
The RenderTask returned synchronously by render() is just a shell; the real scheduling lives in InternalRenderTask:
class RenderTask {
onContinue = null; // called each time rendering pauses, with the "continue" function as argument
onError = null; // called synchronously when rendering errors / is cancelled
get promise() {
return this._internalRenderTask.capability.promise;
}
cancel(extraDelay = 0) {
this._internalRenderTask.cancel(null, extraDelay);
}
}Note that RenderTask itself is not a thenable — await page.render(...) does not wait for rendering to finish; you must await renderTask.promise. Article 5 of the demo series specifically stepped on this pit.
initializeGraphics: A Canvas Serves Only One Task at a Time
initializeGraphics({ transparency = false, optionalContentConfig }) {
if (this.cancelled) return;
if (this._canvas) {
if (InternalRenderTask.#canvasInUse.has(this._canvas)) {
throw new Error("Cannot use the same canvas during multiple render() operations. ...");
}
InternalRenderTask.#canvasInUse.add(this._canvas);
}
const canvasContext = this._canvasContext || this._canvas.getContext("2d", {
alpha: false,
willReadFrequently: !this._enableHWA,
});
this.gfx = new CanvasGraphics(canvasContext, this.commonObjs, this.objs, ...);
this.gfx.beginDrawing({ transform, viewport, transparency, background });
this.operatorListIdx = 0;
this.graphicsReady = true;
this.graphicsReadyCallback?.();
}#canvasInUse is a static WeakSet: a second render() on the same canvas throws outright, and the error message spells out the remedy (use another canvas, or cancel/wait for completion first). The two 2D-context switches each have their own backstory: alpha: false tells the browser the canvas is opaque, saving one blend during compositing; willReadFrequently: !enableHWA declares "rarely read back" when hardware acceleration is on, letting the browser safely keep the canvas on the GPU side.
The Scheduling Loop: rAF and Microtasks
_scheduleNext() {
if (this._useRequestAnimationFrame) {
this.#rAF = window.requestAnimationFrame(() => {
this.#rAF = null;
this._nextBound().catch(this._cancelBound);
});
} else {
Promise.resolve().then(this._nextBound).catch(this._cancelBound);
}
}
async _next() {
if (this.cancelled) return;
this.operatorListIdx = this.gfx.executeOperatorList(
this.operatorList, this.operatorListIdx, this._continueBound, this.stepper, this._operationsFilter
);
if (this.operatorListIdx === this.operatorList.argsArray.length) {
this.running = false;
if (this.operatorList.lastChunk) {
this.gfx.endDrawing();
InternalRenderTask.#canvasInUse.delete(this._canvas);
this.callback(); // back to the `complete` in PDFPageProxy.render
}
}
}Each round of executeOperatorList runs at most about 15ms (the time-slice constant in canvas.js), then returns here via continueCallback to schedule the next round. Display rendering uses requestAnimationFrame to align with frames; print rendering goes through microtasks (useRequestAnimationFrame: !intentPrint) — printing has no notion of frames, and draining microtasks is faster. If the user sets RenderTask.onContinue, scheduling power is handed to the user: at the end of each time slice, onContinue(continueFn) is called, and the user decides when to call continueFn — enabling strategies like "render when idle."
At the end of each round, it checks "executed to the end of the array and this is the final chunk"; only then does it endDrawing and invoke the completion callback. Otherwise the stream hasn't finished pushing, so it hangs with running = false, to be restarted by operatorListChanged() when the next chunk arrives.
Version Constants and Exports
Two constants at the end of the file are injected at build time:
const version = typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_VERSION") : null;
const build = typeof PDFJSDev !== "undefined" ? PDFJSDev.eval("BUNDLE_BUILD") : null;PDFJSDev.eval is replaced with a literal (e.g. "6.3.289") during bundling, and is null when run directly from the source tree. When getDocument assembles docParams, it sends apiVersion to the worker, which uses it to verify the two ends run matching versions — main-thread API and worker coming from different versions is a common deployment accident, and this check blows it up right at the handshake.
Summary
api.js is the main-thread facade of PDF.js: it hides worker communication, streaming transport, font loading, and canvas scheduling behind seven exported names, so the user faces only three verbs — "load document → get page → render."
Where Each Stage of a Full Render Lands in This Article
| Stage | Entry | Key mechanism |
|---|---|---|
| Load | getDocument(src) | One-shot parameter expansion, GetDocRequest handshake, three-branch network stream |
| Wait | loadingTask.promise | The _capability / _setupCapability dual promises |
| Get page | pdf.getPage(n) | Three sets of page caches, pagesMapper page-number translation |
| Render | page.render(params) | intentState bucketing, streaming operatorList, rAF time slices |
| Cancel | renderTask.cancel() | 100ms delayed abort, isolation of tasks in the same bucket |
| Save | pdf.saveDocument() | annotationStorage serialized back |
| Destroy | loadingTask.destroy() | _pendingDestroy seal, Terminate wrap-up |
Six Design Principles
- Validation converges at the boundary.
srcis read only once at the top ofgetDocument, expanded into 35+ clean local variables; the inner 4000 lines never again face "did the user pass it?" - Cross-thread data must be serializable; main-thread-only data can be anything. The
docParams/transportParamssplit is drawn exactly along the "does it crosspostMessage?" line. - Return handles synchronously, advance chains asynchronously.
getDocumentreturnstaskandrender()returnsrenderTask, both synchronously; errors arrive asynchronously via capabilities, and the race betweendestroy()and the startup chain is resolved by the two synchronization points_setupCapabilityand_pendingDestroy. - Streaming beats whole-package delivery. The operatorList is pumped in chunks, text is segmented by
highWaterMark, range data is fetched on demand — first paint never waits for the full parse. - Cancellation should be lazy, cleanup strict. Render cancellation leaves a 100ms reuse window; cleanup executes only when "no tasks, stream exhausted" holds, and a late
objmessage would rather have its bitmap discarded than enter the store. - Capability fallbacks don't disturb upper layers. If a real worker fails, use a fake one; if a font fails, go through
FontFallback;LoopbackPortis isomorphic to a real port — code at and aboveWorkerTransportis unaware of the degradation.
After understanding api.js, the remaining display-layer files can each be positioned by "which stage here does it serve": message_handler.js implements message semantics, canvas.js is the operator set behind executeOperatorList, and fetch_stream.js / network.js are two legs of the three-branch network stream. Their own source walkthroughs are left to separate articles.
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 7: WorkerTransport InitializationOctober 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: 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