Introduction
In the demo of the first article, we obtained the pdf variable via await loadingTask.promise — that promise is resolved by the main thread's GetDoc handler after the worker sends back the GetDoc message; see article 13 for details — and it is an instance of PDFDocumentProxy.
Back to the demo in the first article, lines 8-9:
const page = await pdf.getPage(1); The getPage call
This call passes a single numeric argument:
| Parameter | Value | Meaning |
|---|---|---|
pageNumber | 1 | Page number; the first page is 1 (1-based) |
getPage returns a Promise that resolves to a PDFPageProxy object. This article follows that single call through the full execution chain of getPage.
According to the definition in api.js, the execution flow is as follows.
Lines 773-775: PDFDocumentProxy.getPage: a one-line delegation
getPage(pageNumber) { return this._transport.getPage(pageNumber); } The pdf object itself is just a facade; the real logic lives entirely on this._transport — the WorkerTransport instance created in article 2 (task._transport).
Everything below happens inside WorkerTransport.getPage.
Lines 3031-3037: Validate the arguments
if (!Number.isInteger(pageNumber) || pageNumber <= 0 || pageNumber > this.pagesMapper.pagesNumber) { return Promise.reject(new Error('Invalid page request.')); } Plugging the demo values into each condition:
| Condition | With pageNumber = 1 | Result |
|---|---|---|
!Number.isInteger(pageNumber) | Number.isInteger(1) is true, so the negation is false | Not hit |
pageNumber <= 0 | 1 <= 0 is false | Not hit |
pageNumber > this.pagesMapper.pagesNumber | pagesNumber is 756, 1 > 756 is false | Not hit |
pagesNumber is 756, written from pdfInfo.numPages when the document becomes ready: view source. The demo PDF in this article has 756 pages, so validation passes. If you pass 0, a negative number, a decimal, or a number beyond the page count, this returns a rejected promise immediately.
Q: Why use
Promise.rejectinstead ofthrow?
A: To keep the asynchronous semantics consistent. Callers ofgetPagealwaysawaita promise, so argument errors go through the same error channel. Callers don't have to handle both a synchronous throw and an asynchronous rejection.
Lines 3038-3039: Convert the page number
const pageIndex = pageNumber - 1; // 1 - 1 = 0 const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0 These two lines produce three page numbering schemes, each serving a different consumer:
| Variable | Perspective | Base | Value here | Purpose |
|---|---|---|---|---|
pageNumber | User | 1-based | 1 | The public getPage argument |
pageIndex | Main thread | 0-based | 0 | Key of #pageCache / #pagePromises |
newPageIndex | Worker (original document) | 0-based | 0 | Payload of the GetPage message |
pagesMapper.getPageId(1) returns 1. The demo never moves, deletes, or copies pages, so the mapping is an identity mapping (#pageNumberToId is null), and therefore newPageIndex === pageIndex === 0. The three only diverge once the document has been edited.
PagesMapper: translating between user page numbers and original page order
PagesMapper was created with new PagesMapper() during argument parsing in article 2: view source. It exposes just two things:
-
pagesNumber: the current total page count. Once assigned, the internal#pageNumberToId/#prevPageNumbersare reset tonull, falling back to the identity mapping; -
getPageId(pageNumber)/getPageNumber(pageId): two-way translation (L462-L464, L443-L454). Under the identity mapping they return their input unchanged; only an actual page reorder builds a real table.
Q: Why go through PagesMapper at all?
A: To support page reordering. In an editor scenario, users can move, delete, and copy pages, after which "page 3" may no longer be the original document's page 3. The worker side only understands the original document's page order, so what gets sent to the worker isnewPageIndex, while the two main-thread caches are keyed by the user-facingpageIndex. Under an identity mapping the two are identical and can be ignored. Note:PagesMapperhas nothing to do with the PDF file itself — the PDF contains no such data; it exists purely to hold state while editing.
One sentence to distinguish them: pageIndex is the cache key on the main thread, while newPageIndex is the key sent to the worker.
Lines 3041-3044: Check the cache
const cachedPromise = this.#pagePromises.get(0); // pageIndex = 0 if (cachedPromise) { return cachedPromise; } #pagePromises is a Map<pageIndex, Promise<PDFPageProxy>>. On the first call there is no cache hit, so we continue.
Q: What does the cache actually do?
A: In practice, callingawait pdf.getPage(1)twice in a row returns the samePDFPageProxyobject (page === page2istrue). The second call hits the cached promise directly and no message is sent to the worker.
Three layers of cache: separate stores, separate jobs
WorkerTransport declares all three Maps as class fields in one place: view source
#pageCache = new Map(); #pagePromises = new Map(); #pageRefCache = new Map(); | Field | Key → Value | Responsibility | Typical use |
|---|---|---|---|
#pageCache | pageIndex → PDFPageProxy | Index of created page objects | StartRenderPage looks up a page by index (api.js#L2805); startCleanup iterates it (api.js#L3227) |
#pagePromises | pageIndex → Promise<PDFPageProxy> | Deduplicates in-flight requests and repeat calls | Read at the top of getPage (line 3041 here), written at the end (line 3067) |
#pageRefCache | refStr → pageIndex | Reverse lookup from object reference to page index | Written by getPage (line 3054), read by cachedPageNumber (api.js#L3245-L3255) |
All three share the same lifetime and are cleared when the transport is destroyed: destroy() clears them in turn (api.js#L2611-L2616).
Q: Why keep
#pagePromiseswhen#pageCachealready holds the page object?
A: They solve different problems.#pagePromisesis written before the message even leaves, so that "severalgetPagecalls arriving at the same time only send oneGetPagemessage".#pageCacheis only written after the reply arrives, so that transport-internal events can look up objects by page index. Caching the promise rather than the page is precisely what covers the window before the reply arrives.
Lines 3045-3048: Send the GetPage message to the worker
const promise = this.messageHandler .sendWithPromise('GetPage', { pageIndex: newPageIndex, // 0 }) .then(pageInfo => { // The reply handling is covered in the next section }); messageHandler is the MessageHandler created in article 2. The communication mechanism of sendWithPromise is covered in article 6. All you need to know here is that it sends the message to the worker thread and returns a promise that waits for the worker's reply.
When the worker receives GetPage: view source
handler.on('GetPage', async function ({ pageIndex }) { const page = await pdfManager.getPage(pageIndex); const [rotate, ref, userUnit, view] = await Promise.all([ pdfManager.ensure(page, 'rotate'), pdfManager.ensure(page, 'ref'), pdfManager.ensure(page, 'userUnit'), pdfManager.ensure(page, 'view'), ]); return { rotate, ref, refStr: ref?.toString() ?? null, userUnit, view, }; }); How the worker parses the page object internally is covered in article 14. The reply for the demo's first page is:
pageInfo = { rotate: 0, ref: { num: 4, gen: 0 }, // after structured cloning across threads, the Ref instance degrades to a plain object refStr: '4R', // the result of ref.toString() on the worker side userUnit: 1, view: [0, 0, 595, 842], }; | Field | Value here | Meaning | Matching PDFPageProxy getter |
|---|---|---|---|
rotate | 0 | Clockwise rotation in degrees | rotate |
ref | { num: 4, gen: 0 } | Object reference (the entry Ref of this page in the PDF file) | ref |
refStr | "4R" | String form of the reference, used as a cache key | None (internal only) |
userUnit | 1 | User unit conversion factor | userUnit |
view | [0, 0, 595, 842] | Visible area (PDF user space) | view |
The 595 × 842 of view is exactly A4 paper size in 1/72-inch units, matching the demo's PDF specification.
Q: What is
ref: { num: 4, gen: 0 }? Why isn't it aRefinstance?A: It is PDF's Indirect Reference — every object in a PDF file has a unique
(num, gen)number, and the page object is no exception.num = 4means "object #4",gen = 0is the generation number (almost always 0 in new PDFs; it only bumps up after incremental updates).On the worker side this was a real
Refinstance: view source created viaRef.get(4, 0), with a cached private#str = '4R'. But it cannot cross threads: Web WorkerpostMessageuses Structured Clone, which only copies enumerable own properties.Ref's#stris a private field — it doesn't clone — and class instances degrade to plain objects, leaving only the two public fieldsnumandgen. That's why the main thread receives{ num: 4, gen: 0 }.The main-thread
PDFPageProxy.refgetter (api.js#L1399) simply returns this plain object — it never reconstructs aRef, because the main thread only ever needs to readnum/genand never sends them back as-is. Later messages likeGetPageIndexor the editor'sSaveDocumentrebuild aRefon the worker side from these two fields.Why pre-compute
refStron the worker side? Precisely becauseRef#toString()is lost after crossing threads — the main thread holds only a plain object and can't calltoString. So the worker pre-computesrefStr: ref?.toString() ?? nullin its return value. Two fields, two roles:reffor callers that need to splitnum/gen,refStrfor cache lookups that need a string key.Q: Why is
refStr"4R"rather than"4 0 R"?A:
Ref#toStringreturns the compact string cached inside the object:numRwhengenis zero,numRgenotherwise: view source. The reverse-lookup side,cachedPageNumber, builds its key as${ref.num}Ras well, so both sides use the same format.Q: Why only these 4 properties?
A: They are all metadata of the page: rotation angle, object reference, unit conversion factor, and visible area. Reading them only requires parsing the page object dictionary, without touching the page content stream. The real content parsing (drawing commands) is deferred to the
GetOperatorListmessage atrendertime. This is whygetPagecan return quickly.These 4 properties map one-to-one to getters on
PDFPageProxy:rotate,ref,userUnit, andview. The next article'sgetViewportwill use two of them.
Lines 3049-3068: Handle the reply and create the PDFPageProxy
Once the reply arrives, we enter the .then callback:
.then(pageInfo => { if (this.destroyed) { throw new Error("Transport destroyed"); } if (pageInfo.refStr) { this.#pageRefCache.set(pageInfo.refStr, newPageIndex); // "4R" -> 0 } const page = new PDFPageProxy( pageIndex, // 0 pageInfo, this, this.pagesMapper, this._params.pdfBug // false ); this.#pageCache.set(pageIndex, page); return page; }); this.#pagePromises.set(pageIndex, promise); // line 3067 return promise; // line 3068 Four things happen in sequence:
-
destroyedcheck: while waiting for the reply, the document may already have been destroyed (loadingTask.destroy()); if so, throw an error; -
#pageRefCachereverse-lookup cache:"4R"→0. Used bycachedPageNumber(ref)for reverse lookups — for example, when an outline link jumps to a page, the PDF internally locates pages by object reference (4 0 R), which must be translated back to a page number; - Create the
PDFPageProxyand store it in#pageCache; - Store the promise itself in
#pagePromises.
Q: Why does
#pageRefCachestorenewPageIndexrather thanpageIndex?A: Because it records "which page of the original document this object reference points to", which has nothing to do with the user's perspective. During reverse lookup,
cachedPageNumberfirst obtains the original page index, then translates it back to the user page number viapagesMapper.getPageNumber(pageIndex + 1)(api.js#L3245-L3255). Under an identity mapping the two happen to be equal, but semantically they are not interchangeable.
The PDFPageProxy constructor: assignment only, no parsing
constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) { this._pageIndex = pageIndex; // 0 this._pageInfo = pageInfo; // the pageInfo received above this._transport = transport; this._stats = pdfBug ? new StatTimer() : null; // false, so performance stats are not enabled this._pdfBug = pdfBug; this.commonObjs = transport.commonObjs; // resource container shared across the whole document (fonts, etc.) this.objs = new PDFObjects(); // resource container private to this page this._intentStates = new Map(); // rendering intent state cache, used by render in article 5 this.destroyed = false; this.recordedBBoxes = null; // recorded regions for the editor scenario this.#pagesMapper = pagesMapper; this.imageCoordinates = null; // image coordinate mapping, only populated after rendering } The whole constructor contains not a single await and does no parsing at all; it only moves references around and seeds placeholders:
-
_pageIndexis exposed to the outside aspageNumbervia a getter returning_pageIndex + 1; -
_pageInfois thepageInfoabove; all four getters read from it; -
commonObjsandobjsare resource containers used later during rendering;PDFObjectswill be covered in a separate article; -
recordedBBoxes/imageCoordinatesstaynulluntil the first render.
This is also why the main-thread cost of getPage is so small: the heavy lifting happens entirely on the worker thread, and the main thread only assembles the object.
At this point, the promise returned by getPage(1) resolves to page, and the demo's await receives it.
Summary
In this section we followed the demo through the complete getPage(1) call:
-
PDFDocumentProxy.getPagedelegates in one line toWorkerTransport.getPage; - Argument validation:
1is a valid page number (756 pages in total); - Page number conversion:
pageNumber(1)→pageIndex(0)→newPageIndex(0); under the identity mapping all three agree; - The
#pagePromisescache is checked and missed on the first call; - A
GetPagemessage is sent to the worker, androtate/ref/refStr/userUnit/viewcome back; -
#pageRefCacheis written, thePDFPageProxyis created, and#pageCacheis populated; - The promise is stored in
#pagePromisesand returned; the demo'sawaityieldspage.
With page in hand, the demo's next step is page.getViewport({ scale: 0.5 }) — which will use the view and userUnit from this article's pageInfo. That will be covered in the next article.
About me: Creator of StreamPDF, focused on research and development in AI and PDF.
Original article: https://streampdf.com/blog/pdfjs-deep-dive-from-demo-3-get-page