StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: PDFObjects

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: PDFObjects

Introduction

This installment of the PDF.js source walkthrough series covers main-thread object-readiness coordination in src/display/pdf_objects.js. In the current repository, this file is only 116 lines long and exports just one class, PDFObjects, yet it sits precisely at the boundary between worker parsing and canvas rendering.

The earlier api.js, MessageHandler, and network-stream articles addressed a different part of the journey: how PDF bytes enter the worker, how the operatorList streams back to the main thread, and how messages are dispatched. This article asks the next question: once fonts, images, and glyph paths decoded by the worker reach the main thread, how can the rendering loop read them synchronously when they are ready, yet pause and wait when they are not?

The answer is neither "make every get return a Promise" nor "hand a cross-thread Promise directly to canvas." PDFObjects keeps one stable entry per object on the main thread: the entry's data field holds its current value, while its local Promise only notifies waiters. Actual drawing still uses synchronous reads.

Its responsibility in one sentence: use a small three-state registry to unify object-first and waiter-first timing, while giving document-level and page-level rendering resources explicit readiness and cleanup boundaries.

Module Overview

The file has no explicit constructor, inheritance, or external dependency. Its entire mechanism consists of one sentinel, one entry factory, and one private Map.

PartResponsibility
INITIAL_DATAA private Symbol marking "registered, but not resolved yet"
dataObjCreates an entry containing data and the result of Promise.withResolvers()
#objsStores entries by objId; an entry keeps its identity while waiters are attached
getReads data synchronously without a callback; registers a readiness notification with one
hasProbes resolved state without creating an entry
resolveStores the final data and wakes local waiters; each entry resolves only once
deleteDeletes resolved entries, rejecting absent and unresolved entries
clearCloses any ImageBitmap in the entries, then clears the entire registry
Symbol.iteratorIterates only resolved [objId, data] pairs, skipping pending entries

"Objects" here means artifacts consumed by main-thread rendering, not the worker's XRef indirect-object cache. The current message paths store fonts, FontPath values, images, and patterns here; the operatorList remains separately managed by PDFPageProxy's rendering state.

The Entry Model: Separating Data from Readiness Notification

INITIAL_DATA and dataObj

const INITIAL_DATA = Symbol('INITIAL_DATA');
 
const dataObj = () => ({
  ...Promise.withResolvers(),
  data: INITIAL_DATA,
});

Promise.withResolvers() returns { promise, resolve, reject }. Spreading that result and adding a data field produces one registry entry. This factory only creates a local JavaScript object; it sends no message and starts no decoding work.

FieldRole in PDFObjects
promiseLets one or more get(objId, callback) calls register .then handlers
resolveFulfills promise after the data is stored, waking the callbacks
rejectSupplied by withResolvers, but unused by the current file
dataStarts as INITIAL_DATA, then holds the actual payload after resolution

The key point is that data and the Promise do not carry the same information. The Promise's fulfillment value is not the rendering data; the data is always read from the entry's data field. Whether a callback is registered before or after resolution, it therefore reads the same entry object.

INITIAL_DATA is a module-private Symbol, not null, undefined, or an empty string. Consequently, "there is a value, and that value happens to be null" cannot be confused with "there is no value yet," and no ordinary payload can accidentally equal the sentinel.

Three States, Not Simply Present/Absent

StateEntry in the Map?dataLocal Promiseget without a callback
UnregisteredNoDoes not existDoes not existThrows
Registered, unresolvedYesINITIAL_DATAPendingThrows
ResolvedYesActual data, including nullFulfilledReturns data synchronously

Unregistered and unresolved both mean "cannot read it now" to a synchronous caller, but they differ for an asynchronous caller: get with a callback turns unregistered into registered, while an unresolved entry may already have waiters. resolve can either move an unregistered id directly to resolved or complete an existing pending entry.

The class methods never inspect the Promise's internal state; they determine state solely from entry existence and data === INITIAL_DATA. The Promise is the notification mechanism, while the sentinel is the source of truth for the synchronous state machine.

get: Synchronous Reading and Callback Waiting Are Two Contracts

get(objId, callback = null) {
  // If there is a callback, then the get can be async and the object is
  // not required to be resolved right now.
  if (callback) {
    const obj = this.#objs.getOrInsertComputed(objId, dataObj);
    obj.promise.then(() => callback(obj.data));
    return null;
  }
  // If there isn't a callback, the user expects to get the resolved data
  // directly.
  const obj = this.#objs.get(objId);
  // If there isn't an object yet or the object isn't resolved, then the
  // data isn't ready yet!
  if (!obj || obj.data === INITIAL_DATA) {
    throw new Error(`Requesting object that isn't resolved yet ${objId}.`);
  }
  return obj.data;
}

With a Callback: Register When Necessary, but Return No Promise

The current version directly uses Map.prototype.getOrInsertComputed: if the key exists, it returns the existing entry; otherwise it calls dataObj to create and insert an entry, then returns that new entry. This is not the hand-written "get, then set" expansion familiar from older code, and this file itself supplies no fallback for older Map implementations.

Whether the entry was pending or already resolved, the next line is always obj.promise.then(...). Multiple callbacks can therefore be registered for the same object, all sharing one readiness signal rather than each creating its own cross-thread request.

The callback wrapper is () => callback(obj.data) and does not use the .then argument. The reason appears in resolve: the entry Promise is fulfilled with no argument, so the callback reads the latest data from the closed-over obj when it runs.

Even if the object is already resolved, a Promise callback is only queued as a microtask; it does not run synchronously inside the get call stack. "Right away" in the source comment means that no new resolution event is needed, not that the callback is synchronous.

The callback branch always returns null, not a Promise and not the future data. A caller cannot use await objs.get(id, cb) to wait for readiness; it has registered a callback rather than received an awaitable handle.

Without a Callback: Throw When Not Ready

The no-callback branch uses ordinary Map.get and never silently creates an entry. Both an absent entry and data === INITIAL_DATA throw the same Requesting object that isn't resolved yet error; this branch never degrades into asynchronous waiting.

When resolved, it directly returns obj.data. Because state is determined by the sentinel, the null produced by resolve(id) is a valid synchronous return value and is not mistaken for pending.

This is exactly the attitude the rendering hot path needs: before an actual drawing operator runs, its object must be ready. Otherwise the dependency scheduler should pause ahead of time, rather than forcing every fill, drawImage, or font operation to become an asynchronous function.

has: A Side-Effect-Free Readiness Probe

has(objId) {
  const obj = this.#objs.get(objId);
  return !!obj && obj.data !== INITIAL_DATA;
}

has uses the same state test as get without a callback, but turns "not ready, so throw" into false. Both unregistered and pending produce false; resolved produces true, even when data is null.

It neither registers the object nor attaches a callback. Canvas dependency operators use it first to decide whether execution can continue; the commonobj / obj message handlers also use it to skip duplicate objects that have already resolved.

Do not interpret has(id) === false as "the Map definitely has no id." It deliberately collapses unregistered and registered-but-unresolved into one consumer-facing meaning: the object cannot be read synchronously right now.

resolve: Store Data First, Then Signal Readiness

resolve(objId, data = null) {
  const obj = this.#objs.getOrInsertComputed(objId, dataObj);
  if (obj.data !== INITIAL_DATA) {
    throw new Error(`Object already resolved ${objId}.`);
  }
  obj.data = data;
  obj.resolve();
}

getOrInsertComputed covers both entry paths: when the object arrives first, it creates an entry and resolves it immediately; when callbacks were registered first, it retrieves the original pending entry instead of replacing it. The Promise held by waiters is therefore always the one ultimately awakened.

The duplicate-resolution check happens before the write. Whenever data !== INITIAL_DATA, resolve throws Object already resolved; the second call's data cannot overwrite the first, and the entry Promise cannot be reused.

"Only once" here means once per registration lifetime, not that an id can never be used again. After delete or clear removes an entry, the same id can establish a new one; on the original entry, however, resolve is not a set operation for updating cached values.

The default parameter is an easy detail to miss in the current version: resolve(objId) writes null into data. Because pending is represented by INITIAL_DATA, that object is still resolved, has returns true, and get without a callback returns null.

The assignment order also cannot be reversed. obj.data = data completes synchronously before obj.resolve() fulfills the Promise, so a later microtask running callback(obj.data) is guaranteed to observe the final value.

obj.resolve() receives no argument, so the Promise's fulfillment value is undefined. Once again, it is not a data channel. reject likewise plays no role in the current implementation: whether a processing failure becomes some error value stored as data, or triggers fallback rendering, is decided by the higher-level message handler.

delete: Why Unresolved Entries Cannot Be Removed

delete(objId) {
  const obj = this.#objs.get(objId);
  if (!obj || obj.data === INITIAL_DATA) {
    // Only allow removing the object *after* it's been resolved.
    return false;
  }
  this.#objs.delete(objId);
  return true;
}

Returning false for an absent id is straightforward; returning false for pending protects already registered waiters. Imagine that pending entry A could be deleted: its callbacks would still be attached to A's Promise, but a later resolve would create entry B through getOrInsertComputed and awaken B instead, leaving A's callbacks with no continuation.

Rejecting deletion keeps the waiting lifetime intact: either the original entry resolves, or a higher-level whole-owner shutdown ends its meaning. The registry cannot leave waiters behind while silently replacing the registration on which they depend.

This method has a concrete use in the font-path flow. After FontFaceObject.getPathGenerator compiles raw drawing commands into a reusable path, it deletes the raw object that is no longer needed:

const objId = this.loadedName + '_path_' + character;
let cmds;
try {
  cmds = objs.get(objId);
} catch (ex) {
  warn(`getPathGenerator - ignoring character: "${ex}".`);
}
const path = makePathFromDrawOPS(cmds?.path);
 
if (!this.fontExtraProperties) {
  // Remove the raw path-string, since we don't need it anymore.
  objs.delete(objId);
}
return (this.compiledGlyphs[character] = path);

The synchronous get ensures that, on the normal path to deletion, the object has already resolved; if it was not ready, the preceding exception path does not remove the pending entry. The compiled result enters compiledGlyphs, allowing the raw FontPath entry to leave the registry.

Notice that delete itself only removes a Map entry and never calls bitmap.close(). Bulk bitmap release is clear's responsibility; these are not interchangeable cleanup APIs.

clear: Release Bitmaps First, Then Remove Everything

clear() {
  for (const { data } of this.#objs.values()) {
    data?.bitmap?.close(); // Release any `ImageBitmap` data.
  }
  this.#objs.clear();
}

The loop visits every entry and calls ImageBitmap.close() on resolved image data that has a bitmap, explicitly releasing its graphics resources. null or the pending INITIAL_DATA sentinel is safely skipped by optional chaining. Map.clear() then removes every registration, leaving other ordinary data to garbage collection.

This releases data.bitmap; it does not assume that every object has its own close method. Font bytes, compiled paths, and ordinary pixel arrays each have their own lifetime. PDFObjects.clear special-cases only this shared bitmap-resource convention.

clear is not a per-object cancellation protocol. It neither resolves nor rejects the Promise of a pending entry, and callbacks attached to an old entry are not notified merely because clear ran. Correctness depends on higher-level lifecycle code stopping consumers first, not on treating this small class as a general-purpose task canceller.

During page cleanup, PDFPageProxy.#tryCleanup first verifies that no renderTask is active and that the operatorList has reached its last chunk; it then clears _intentStates and calls objs.clear(). A late obj message subsequently sees _intentStates.size === 0 and directly closes the arriving imageData.bitmap rather than writing it back into the cleaned page cache.

During document destruction, WorkerTransport.destroy first marks the transport as destroyed so later object messages are ignored, waits for page tasks and the worker Terminate handshake to finish, and only then runs commonObjs.clear(). Explicit document cleanup() likewise clears document-level objects after every page has cleaned up successfully.

The simple implementation of clear and the strict cleanup ordering in api.js are therefore complementary: the former releases resources and removes entries, while the latter guarantees that nobody may still legitimately wait or continue writing at that point.

Symbol.iterator: Iterate Only Consumable Data

*[Symbol.iterator]() {
  for (const [objId, { data }] of this.#objs) {
    if (data !== INITIAL_DATA) {
      yield [objId, data];
    }
  }
}

This is a generator method, so a PDFObjects instance can be placed directly in for...of. It yields [objId, data], not the internal entry and not a Promise.

Pending entries are skipped individually; they neither block the whole iteration nor trigger a wait. A resolved null, however, is still yielded. Consumers therefore see only payloads that can be inspected synchronously.

The current CopyLocalImage branch of commonobj relies on this interface. When the worker wants to reuse an image already decoded for some main-thread page, the handler scans each page's objects:

for (const pageProxy of this.#pageCache.values()) {
  for (const [, data] of pageProxy.objs) {
    if (data?.ref !== imageRef) {
      continue;
    }
    if (!data.dataLen) {
      return null;
    }
    const copy = structuredClone(data);
    if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
      copy.CopyLocalImage = true;
    }
    this.commonObjs.resolve(id, copy);
    return data.dataLen;
  }
}

Only resolved page-level data participates in ref matching. On a match, the main thread uses structuredClone to copy the data, resolves the copy into document-level commonObjs, and returns dataLen as the answer to this commonobj request, avoiding another decode.

Two kinds of Promise are present here and must not be conflated: the CopyLocalImage request-response uses MessageHandler.sendWithPromise, whereas commonObjs.resolve awakens local main-thread object waiters. They serve different parties and directions.

Two Registry Levels: commonObjs and objs

PDFObjects itself does not choose a scope; scope comes from where api.js creates each instance. Every WorkerTransport creates one document-level registry:

this.commonObjs = new PDFObjects();

Every PDFPageProxy shares the transport's instance while creating its own page-level registry:

this.commonObjs = transport.commonObjs;
this.objs = new PDFObjects();
RegistryOwner and sharing scopeIncoming messageMain current dataCleanup boundary
commonObjsOne per WorkerTransport, shared by all pagescommonobjFonts, glyph paths, global images, some patterns, copied local imagesDocument cleanup / destroy
objsOne per PDFPageProxyobjImages and patterns for that pagePage cleanup / destroy

PDFPageProxy.render passes both to InternalRenderTask, which passes them to CanvasGraphics. Canvas chooses a pool by id convention: a string beginning with g_ uses commonObjs; anything else uses the page-level objs.

getObject(opIdx, data, fallback = null) {
  if (typeof data === "string") {
    this.dependencyTracker?.recordNamedDependency(opIdx, data);
    return data.startsWith("g_")
      ? this.commonObjs.get(data)
      : this.objs.get(data);
  }
  return fallback;
}

Actual image and pattern reads go through this synchronous getObject. Font handling in setFont is even more direct: it calls this.commonObjs.get(fontRefName); when built-in glyph paths are needed, paintChar passes the same commonObjs to font.getPathGenerator.

The worker also chooses a message according to resource scope: ordinary page images are sent through obj, while images inside Type3 fonts or selected for global caching are sent through commonobj. The two cache levels are therefore not a classification guessed by the main thread after arrival; they are a routing protocol shared by producer and consumer.

Message Arrival Does Not Mean Object Readiness

The current commonobj handler decides when and with what value to call resolve according to type:

TypeMain-thread processingFinal registry
FontConstructs a FontFaceObject, then waits for font binding or the fallback chaincommonObjs
FontPathWraps the payload in FontPathInfocommonObjs
ImageUses the supplied exportedDatacommonObjs
PatternConverts through PatternInfo.getIR()commonObjs
CopyLocalImageScans page caches and clones a reusable imagecommonObjs
Image / Pattern in objResolves directly after validating that the page is still activeThe corresponding page's objs

The font path best illustrates the distance between "data arrived" and "ready to render":

this.fontLoader
  .bind(font)
  .catch(() => messageHandler.sendWithPromise('FontFallback', { id }))
  .finally(() => {
    if (!font.fontExtraProperties) {
      // Immediately release the `font.data` property once the font
      // has been attached to the DOM, since it's no longer needed,
      // rather than waiting for a `PDFDocumentProxy.cleanup` call.
      // Since `font.data` could be very large, e.g. in some cases
      // multiple megabytes, this will help reduce memory usage.
      font.clearData();
    }
    this.commonObjs.resolve(id, font);
  });

bind(font) may await native FontFace.loaded, or it may use DOM font-loading detection. If binding fails, catch returns the FontFallback request Promise; whether binding succeeds directly or enters fallback, the chain ultimately resolves the object in finally.

PDFObjects therefore neither creates a Promise for font loading nor observes a Promise from the worker. Only after api.js performs the required wrapping, binding, and fallback on the main thread does it explicitly announce that "this id can now be consumed by synchronous rendering."

The font.clearData() inside finally is another resource-management layer: when extra font properties need not be retained, the font bytes are released immediately after attachment to the DOM rather than waiting for a wholesale commonObjs.clear().

In addition, the font-error branch of commonobj calls resolve(id, exportedError) rather than rejecting the entry Promise. This is consistent: PDFObjects standardizes the readiness semantics of "a final value now exists" and does not dictate how higher layers handle an error value.

An entry Promise never crosses a thread. The worker sends structured-cloneable or transferable data through send("commonobj", ...) or send("obj", ...); only after the main-thread message handler receives it does that handler call the local resolve. Cross-thread request-response Promises belong to MessageHandler, while entry Promises belong to main-thread PDFObjects. They must not be stitched together into an imaginary single Promise chain.

How the Rendering Loop Uses Both Forms of get

Dependency waiting is centralized in the OPS.dependency branch of CanvasGraphics.executeOperatorList, rather than scattered across every drawing operator:

for (const depObjId of fnArgs) {
  this.dependencyTracker?.recordNamedData(depObjId, i);
  const objsPool = depObjId.startsWith('g_') ? commonObjs : objs;
 
  // If the promise isn't resolved yet, add the continueCallback
  // to the promise and bail out.
  if (!objsPool.has(depObjId)) {
    objsPool.get(depObjId, continueCallback);
    return i;
  }
}

When has is true, the loop moves past the dependency operator directly, and subsequent drawing operators read data through synchronous get without a callback. Only when has is false does it use callback-style get to register continueCallback, then return at the current index i.

Returning i rather than i + 1 matters. InternalRenderTask stores it as operatorListIdx; after the object becomes ready, the next pass checks the same dependency operator again, verifies that every dependency can be read synchronously, and only then executes the following drawing operations.

Here continueCallback is InternalRenderTask._continueBound. Although the entry callback passes obj.data, _continue() does not use that argument; it treats the notification solely as a signal to reschedule. The data is still fetched synchronously from the registry by subsequent operators.

Timeline 1: The Object Resolves Before Rendering Registers the Dependency

Consider an image already resolved into the page cache by an obj message:

StepActionResult
1The message handler runs pageProxy.objs.resolve(id, imageData)The entry moves directly from unregistered to resolved
2The rendering loop later reaches OPS.dependency, and has(id) is trueNo waiter is registered and no pause occurs
3The image operator calls getObject, entering get without a callbackimageData is returned in the same call stack

This path imposes no waiting cost on the main-thread drawing hot path. Although the entry Promise is already fulfilled, the rendering loop need not touch it at all; has and synchronous get are sufficient.

If other code explicitly calls get(id, callback) at this point, getOrInsertComputed returns the resolved entry, and the callback is still queued as a microtask through .then on the fulfilled Promise; this does not affect the synchronous nature of no-callback reads.

Timeline 2: Rendering Registers a Waiter Before the Object Resolves

The worker adds an image dependency to the operatorList before decoding the image, so the following path is equally normal:

StepActionResult
1Canvas reaches the dependency, and has(id) is falseThe object may be unregistered or already pending
2get(id, continueCallback) reuses or creates the entry and registers .thenget returns null, and executeOperatorList returns the current i
3The image message arrives later, and api.js calls resolve(id, imageData)It stores data first, then fulfills the local Promise
4A Promise microtask invokes _continueBoundThe render task schedules another pass instead of drawing inside the message callback
5The pass retries from index i, and has(id) is trueIt passes the dependency, and the drawing operator reads the image synchronously

A font whose "message arrived but is not ready" also follows the second path: commonobj arrival does not make has true. Waiting rendering continues only after the font binding/fallback chain reaches finally and calls resolve.

Both paths share the same implementation because getOrInsertComputed preserves entry identity, data stores synchronous state, and the Promise only provides notification. Which side arrives first changes only the order of state transitions, not the final reading contract.

Design Takeaways

1. A Sentinel Expresses Three States Better Than Truthiness

Using only the return value of Map.get(id) would make it impossible to distinguish unregistered, pending, null, and other falsy values. A private Symbol separates "unresolved" from every valid payload, which also makes the default null in resolve safe.

2. One Stable Entry Serves Both Producer and Waiters

Both get and resolve obtain the same entry through getOrInsertComputed. Registering a callback first does not block later data, and resolving data first does not deprive later callbacks of notification; no separate waiter array or message-replay cache is needed.

3. Asynchrony Lives Only at the Dependency Boundary, Leaving Drawing Synchronous

PDFObjects does not turn every canvas operator into an async function. Dependency operators handle suspension and resumption, while actual drawing uniformly reads synchronously, keeping the rendering state machine, error paths, and drawing order simple.

4. The Consumer Defines Readiness, Not Message Arrival

An image can resolve as soon as it arrives; a font must wait for main-thread binding or fallback to finish. PDFObjects supplies a neutral notification mechanism, while api.js folds each resource's distinct preparation work into the same id-readiness contract.

5. Single Resolution and Pending-Deletion Guards Protect Waiter Relationships

Forbidding duplicate resolution prevents the final value from being overwritten; forbidding deletion of pending entries prevents old waiters from losing their connection to a new entry. Together, these constraints close the notification relationship within one registration lifetime.

6. Cache Scope and Resource Release Must Be Designed Together

Document-level and page-level pools prevent cross-page resources from being cleaned too early while allowing page resources to be reclaimed independently. clear explicitly closes ImageBitmap values, the iterator exposes only resolved data, and api.js adds the late-message guard after cleanup, completing the resource-ownership chain.

Conclusion

PDFObjects is neither a general-purpose cache nor a cross-thread Promise wrapper. It is a three-state registry concerned only with rendering-object lifetimes: unregistered, registered but unresolved, and resolved. INITIAL_DATA drives synchronous decisions, while a local Promise supplies asynchronous notification.

Once these 116 lines are clear, two apparently contradictory rendering behaviors become consistent: CanvasGraphics can pause through a callback when a dependency is not ready, yet use synchronous get when actual drawing begins. commonObjs and objs determine how long data can be shared, the timing of resolve determines when it is truly usable, and delete / clear / the iterator guard waiter relationships and resource-release boundaries.

Between messages and pixels, PDF.js does not spread asynchrony throughout the renderer; it compresses asynchrony onto the single edge where "a dependency is not ready." That is the value of this small file in the display layer.

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