StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 5: render

October 27, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 5: render

Introduction

In the previous articles, we obtained the page (PDFPageProxy) and the viewport (PageViewport) one by one, and adjusted the canvas size. Back to the demo from the first article, the final step, lines 19-22:

const renderContext = {
  canvasContext: context,
  viewport,
};
await page.render(renderContext);

An object parameter is passed in:

  • canvasContext: the 2d context of the canvas. Its value is the return value of canvas.getContext('2d')
  • viewport: the viewport object. Its value is the return value of getViewport({ scale: 0.5 }) from the previous article

render returns a RenderTask object. This article follows this call into the source code.

The render call

Based on the definition of PDFPageProxy.render in api.js, the execution flow is as follows.

Step 1: Destructure the parameters

render({
  canvasContext,
  canvas = canvasContext.canvas,
  viewport,
  intent = "display",
  annotationMode = AnnotationMode.ENABLE,
  transform = null,
  background = null,
  optionalContentConfigPromise = null,
  annotationCanvasMap = null,
  pageColors = null,
  printAnnotationStorage = null,
  isEditing = false,
  recordImages = false,
  recordOperations = false,
  operationsFilter = null,
}) { ... }

There are 16 parameters in total. The demo only passes 2, and the rest use default values:

VariableResultLogic
canvascanvas elementIf not passed, take canvasContext.canvas, retrieved from the context
intent"display"Rendering intent: display
annotationMode1 (ENABLE)Render annotations
transformnullAdditional transform matrix
backgroundnullBackground color
optionalContentConfigPromisenullLayer config, default value filled in later
annotationCanvasMapnullAnnotation canvas map
pageColorsnullPage colors (accessibility scenario)
printAnnotationStoragenullAnnotation storage for printing
isEditingfalseWhether in editing mode
recordImagesfalseWhether to record image coordinates
recordOperationsfalseWhether to record drawing operations
operationsFilternullOperation filter

Then the first line this._stats?.time("Overall"): _stats is null (in Part 3, pdfBug was false when PDFPageProxy was constructed), so it is skipped.

Step 2: Compute the rendering intent

const intentArgs = this._transport.getRenderingIntent(
  "display", // intent
  1,         // annotationMode
  null,      // printAnnotationStorage
  false      // isEditing
);
const { renderingIntent, cacheKey } = intentArgs;

Inside getRenderingIntent is a set of bit operations (RenderingIntentFlag). Substituting the demo's values:

  • intent is "display" → renderingIntent = 0x02 (DISPLAY)
  • Not PRINT → annotationStorage takes the globally shared AnnotationStorage on the transport
  • annotationMode is ENABLE → no flag bit added
  • isEditing is false, not an opList call → no flag bit added
  • No modifications in the storage → modifiedIds = { ids: Set(0), hash: "" }
  • cacheKey = [2, "", ""].join("_") → "2__"

It returns { renderingIntent: 2, cacheKey: "2__", annotationStorageSerializable: { map: null, hash: "", transfer: undefined }, modifiedIds }.

Q: What is cacheKey for? A: On the same page object, different rendering intents (display / print / annotations modified...) each maintain an independent set of operator lists and render tasks, stored in the page's _intentStates Map, keyed by cacheKey. As long as the intents differ, the caches never interfere with each other. The demo is a first-time render, so "2__" corresponds to a brand-new intentState.

Step 3: Prepare the intentState

this.#pendingCleanup = false;
optionalContentConfigPromise ||=
  this._transport.getOptionalContentConfig(2);
const intentState = this._intentStates.getOrInsertComputed("2__", makeObj);
const intentPrint = !!(2 & 0x04); // false

Three things happen in sequence:

  1. #pendingCleanup = false: cancel any pending cleanup task, ensuring no memory reclamation happens during rendering;
  2. optionalContentConfigPromise: the document's layer configuration (OCProperties). If the caller didn't pass it, it is fetched from the worker (the transport layer caches it). The layer mechanism will be covered in a separate article;
  3. intentState: the state slot for this rendering intent, created as an empty object on first use.

Step 4: First render, start fetching the operator list

if (!intentState.displayReadyCapability) {
  intentState.displayReadyCapability = Promise.withResolvers();
  intentState.operatorList = {
    fnArray: [],      // opcode array
    argsArray: [],    // argument array
    lastChunk: false, // whether all chunks have been received
    separateAnnots: null,
  };
  this._pumpOperatorList(intentArgs);
}

If displayReadyCapability doesn't exist, it means this intent has never requested an operator list, so the request is initiated immediately.

_pumpOperatorList establishes a streaming channel with the worker via messageHandler.sendWithStream("GetOperatorList", { pageId: 0, pageIndex: 0, intent: 2, cacheKey: "2__", ... }): the worker parses the page's content stream and pushes drawing instructions (opcodes + arguments) back to the main thread in chunks; each time the main thread receives a chunk, it appends it to operatorList and notifies all render tasks under this intentState that "new instructions are available to draw." Both the streaming communication and the worker-side instruction parsing will be covered in a separate article.

Q: Why streaming instead of returning everything at once? A: Parsing instructions for large pages takes a long time. Streaming lets rendering start as early as possible (drawing while receiving), so first paint is faster; cancellation or failure can also be cleaned up partially. Correspondingly, once the worker parses the first batch of displayable content, it sends back a StartRenderPage message that resolves the displayReadyCapability above — this is one of the preconditions awaited in Step 7.

Step 5: Define the completion callback

const complete = error => { ... };

The complete closure handles the wrap-up: removing this task from intentState.renderTasks; on error, rejecting the capability and aborting the operator list fetch; on success, resolving the capability. The demo doesn't enable any recording switches, so all related branches are skipped.

Step 6: Create the InternalRenderTask

const internalRenderTask = new InternalRenderTask({
  callback: complete,
  params: {
    canvas,
    canvasContext,
    dependencyTracker: null,
    imagesTracker: null,
    viewport,
    transform: null,
    background: null,
  },
  objs: this.objs,                  // page-level resource container (created in Part 3)
  commonObjs: this.commonObjs,      // document-wide shared resource container
  annotationCanvasMap: null,
  operatorList: intentState.operatorList, // shares the same object with the streaming fetch
  pageIndex: 0,
  canvasFactory: this._transport.canvasFactory, // the DOMCanvasFactory from Part 2
  filterFactory: this._transport.filterFactory,
  useRequestAnimationFrame: true,   // not printing
  pdfBug: false,
  pageColors: null,
  enableHWA: false,
  operationsFilter: null,
});
 
(intentState.renderTasks ||= new Set()).add(internalRenderTask);
const renderTask = internalRenderTask.task;

Two key actions happen in the constructor:

  • this.capability = Promise.withResolvers() — the success/failure of rendering converges here;
  • this.task = new RenderTask(this) — the externally exposed handle, i.e., the final return value of render.

Note that operatorList is passed by reference: InternalRenderTask shares the same list object with the streaming fetch from Step 4, so instructions can be consumed as soon as they arrive.

Step 7: Wait for the preconditions, start drawing

Promise.all([
  intentState.displayReadyCapability.promise, // operator list has received the first displayable content
  optionalContentConfigPromise,               // layer config is ready
])
  .then(([transparency, optionalContentConfig]) => {
    if (this.destroyed) {
      complete();
      return;
    }
    if (!(optionalContentConfig.renderingIntent & 2)) {
      throw new Error(
        "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " +
          "and `PDFDocumentProxy.getOptionalContentConfig` methods."
      );
    }
    internalRenderTask.initializeGraphics({ transparency, optionalContentConfig });
    internalRenderTask.operatorListChanged();
  })
  .catch(complete);
 
return renderTask;

Once both preconditions are ready:

  1. destroyed check: the page may have been destroyed while waiting;
  2. The layer config's intent must be compatible with this render's intent, otherwise an error is thrown — layer configs are cached per intent, and mixing them would corrupt layer state;
  3. initializeGraphics: creates the CanvasGraphics (the object that actually executes drawing instructions), and checks that the same canvas must not be used concurrently by two render tasks (#canvasInUse is a WeakSet; reuse throws directly);
  4. operatorListChanged: starts consuming the instructions that have already arrived in operatorList, drawing them onto the canvas one by one.

The drawing details of initializeGraphics and CanvasGraphics are covered in Article 10.

Return value: renderTask

The .then chain above advances asynchronously in the background, while render itself returns renderTask synchronously.

Q: Does await page.render(renderContext) in the demo wait for rendering to complete? A: No. render returns a RenderTask object, which only has a promise getter and no then method — it is not a thenable. Awaiting a non-thenable value resolves immediately with that value itself — this await actually waits for nothing. The correct way is:

const renderTask = page.render(renderContext);
await renderTask.promise;

The demo looks fine only because there is no code after the await; rendering proceeds in the background and draws onto the canvas. If subsequent code depends on "rendering has completed" (for example, exporting the canvas as an image), you must await renderTask.promise.

At this point, the last line of the demo has finished executing, and the first page is rendered onto the canvas.

Summary

This article followed the demo through the complete render({ canvasContext, viewport }) call:

  1. Destructure 16 parameters; the demo only passes canvasContext and viewport;
  2. getRenderingIntent bit operations: renderingIntent = 2, cacheKey = "2__";
  3. Prepare the intentState and fill in the layer config promise;
  4. First render: establish the GetOperatorList streaming channel, drawing while receiving;
  5. Create the InternalRenderTask and the external handle RenderTask;
  6. Once the preconditions are ready, initializeGraphics + operatorListChanged start the drawing;
  7. renderTask is returned synchronously; the rendering completion signal lives on renderTask.promise.

Looking back at the demo from the first article: getDocument → getPage → getViewport → render, each step merely "initiates" — the real parsing and drawing happens inside the worker thread (pdfManager, GetOperatorList) and the main thread's CanvasGraphics. Starting from the next article, we enter these "heavyweight players."

Related articles