StreamPDFStreamPDF
Back to blog

PDF.js Source Walkthrough: MessageHandler

October 27, 2026 · StreamPDF Team

PDF.js Source Walkthrough: MessageHandler

Introduction

This is the installment of the PDF.js source walkthrough series covering the "cross-thread message hub": src/shared/message_handler.js (538 lines).

The worker thread parses the PDF, the main thread renders to canvas — all communication between them (except Transferable zero-copy ownership transfers) goes through this single class: one MessageHandler instance on the main-thread side, another on the worker side. Both instances share the same comObj (a MessagePort or Worker object).

Earlier api.js articles deferred to it four times as "covered separately":

  • sendWithPromise("GetDocRequest", docParams) — the handshake channel of article 2, on getDocument;
  • send("Ready", null) — the declaration that closes out the handshake;
  • sendWithStream("GetOperatorList", { ... }) — the streaming operatorList of article 5, on render;
  • sendWithStream("GetTextContent", { ... }) — the streaming text blocks for the text layer.

Those caller-side views only showed "how the API is used." This article flips the perspective — standing at the protocol-implementation layer, we look at how messages are wrapped, dispatched, callback-driven, cancelled, and flow-controlled.

MessageHandler's responsibility in one sentence: on top of the minimal primitive comObj.postMessage, it builds three communication primitives with different semantics — fire-and-forget (send), request-reply (sendWithPromise), and streaming (sendWithStream).

At the end of the file there are only two exports: the MessageHandler class itself, and the wrapReason helper that must be reused to preserve Error identity across threads.

Module Overview

message_handler.js can be cut into seven blocks:

BlockLinesResponsibility
CallbackKind / StreamKind constants26-40Type enums for the two message-body categories
wrapReason44-74Preserves Error types across thread boundaries
MessageHandler constructor76-93Four Map containers + message listener
#onMessage dispatch main loop95-156Three-way dispatch: callback / action / stream
The three send APIs158-294send / sendWithPromise / sendWithStream
Both ends of a stream: #createStreamSink + #processStreamMessage296-519Bidirectional backpressure for sink (consumer side) / controller (producer side)
#deleteStreamController + destroy521-535Stream cleanup and end of lifecycle

Message Envelope: the Three-Layer Wrapping of postMessage

postMessage payloads can only be JSON-serializable objects. MessageHandler defines its own protocol — every message crossing comObj carries these fields:

// Common envelope portion
{
  sourceName:   "main" | "worker" | "d1" | "d1_worker", // message source
  targetName:   "main" | "worker" | "d1" | "d1_worker", // message destination
  // ... shape-specific fields
}

On top of the envelope sit three message shapes:

ShapeKey fieldsPeer-side actionCorresponding API
Action (fire-and-forget)action, dataExecute, no reply sentsend(action, data)
Action + Callback (request-reply)action, data, callbackIdExecute, send back a callback with callbackIdsendWithPromise(action, data)
Callback (pure reply)callback, callbackId, data/reasonThis is the reply to a request; it is not routed to an actionHandler—
Stream Startaction, data, streamId, desiredSizeCreate sink, execute action (synchronously), reply with START_COMPLETEsendWithStream(action, data)
Stream Controlstream, streamId8 kinds of stream control: PULL / CANCEL / CANCEL_COMPLETE, etc.Automatically sent inside ReadableStream
Stream Datastream, streamId, chunkENQUEUE / CLOSE / ERROR, sent by sinkActively invoked by sink-side code

#onMessage dispatches down three paths by field presence, mutually exclusive and with strict ordering:

if (data.stream) { /* streaming control or data */ }
if (data.callback) { /* reply carrying a callbackId */ }
// anything left is an action

sourceName / targetName form a soft route: #onMessage does a single exact match — data.targetName !== this.sourceName and the message is dropped. When multiple documents coexist (two parallel getDocument calls), docId is baked into the name (d1 / d1_worker / d2 / d2_worker), so each MessageHandler is naturally isolated and never crosses wires.

MessageHandler Class: Four Tables + One Listener

constructor(sourceName, targetName, comObj) {
  this.sourceName = sourceName;
  this.targetName = targetName;
  this.comObj = comObj;
  this.callbackId = 1;
  this.streamId = 1;
  this.streamSinks = Object.create(null);       // streamId → sink (I am the data receiver here)
  this.streamControllers = Object.create(null); // streamId → controller (I am the data producer here)
  this.callbackCapabilities = Object.create(null); // callbackId → Promise.withResolvers()
  this.actionHandler = Object.create(null);     // actionName → handler
  comObj.addEventListener("message", this.#onMessage.bind(this), {
    signal: this.#messageAC.signal,
  });
}

The four tables' division of labor is straightforward:

  • actionHandler: remote actions I can respond to. Registered via MessageHandler.on("GetPage", handler), triggered when the remote calls sendWithPromise("GetPage", ...);
  • callbackCapabilities: I send a request and wait for the remote to reply with a callbackId. Key is the callback id, value is Promise.withResolvers(). Ids are assigned in ascending order and deleted after use;
  • streamControllers: I call sendWithStream, the remote is the data producer. On my side I hold the ReadableStream controller; the remote enqueues data into this sink;
  • streamSinks: the remote calls sendWithStream, I am the data producer. On my side I hold a sink object (with enqueue / close / error methods); the remote ReadableStream's pull/cancel controls me.

Design note: The "my side / remote side" relationship across the four tables is relative. When the main thread calls sendWithStream("GetOperatorList"), the main thread holds streamControllers[streamId] and the worker holds streamSinks[streamId]; when the worker calls sendWithStream("GetReader") (pushing the full PDF data stream to the main thread), the relationship is exactly reversed. Each MessageHandler instance is simultaneously the producer for some streams and the consumer for others — which is why both Map tables are needed.

callbackId / streamId are both auto-increment integers, independently allocated by each MessageHandler. The worker and main thread each start from 1 and will never collide — because their MessageHandlers are two distinct instances.

The listener uses an AbortController signal — on destroy(), abort() removes the listener, and any messages that comObj.postMessage delivers afterwards will no longer invoke any callback.

on() / send() / sendWithPromise(): The First Two Shapes

on(): Register an action handler

on(actionName, handler) {
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    assert(typeof handler === "function",
      'MessageHandler.on: Expected "handler" to be a function.');
  }
  const ah = this.actionHandler;
  if (ah[actionName]) {
    throw new Error(`There is already an actionName called "${actionName}"`);
  }
  ah[actionName] = handler;
}

Only one handler may be registered per action name — a second call throws. This means when the remote says "I want to send a GetPage", the receiver has exactly one implementation, so you never end up debugging which handler actually responded.

In api.js, WorkerTransport.setupMessageHandler() calls .on(...) twenty-plus times in a row to register: GetDoc, DocException, PasswordRequest, commonobj, obj, StartRenderPage, DocProgress, FetchBinaryData, SaveDocument, Terminate, and more. All message types for a single doc are concentrated in this batch of .on calls — it is the core of how WorkerTransport translates "worker↔main-thread communication" into semantics.

send(): fire-and-forget

send(actionName, data, transfers) {
  this.comObj.postMessage({
    sourceName: this.sourceName,
    targetName: this.targetName,
    action: actionName,
    data,
  }, transfers);
}

The simplest path. No callbackId, no streamId — the message is sent and done. api.js uses it in two places:

  • messageHandler.send("Ready", null) — the declaration closing out the handshake;
  • messageHandler.send("configure", { verbosity }) — PDFWorker passes the log level to the worker.

sendWithPromise(): request-reply

sendWithPromise(actionName, data, transfers) {
  const callbackId = this.callbackId++;
  const capability = Promise.withResolvers();
  this.callbackCapabilities[callbackId] = capability;
  try {
    this.comObj.postMessage({
      sourceName: this.sourceName,
      targetName: this.targetName,
      action: actionName,
      callbackId,
      data,
    }, transfers);
  } catch (ex) {
    capability.reject(ex);
  }
  return capability.promise;
}

The only difference from send: it attaches a callbackId and records the promise resolver in the table. When the remote processes the action and sees the callbackId field present, it sends back the result (or exception) as a callback-shaped message, and the callback branch of #onMessage invokes the resolver.

Two subtle points to note:

  1. The synchronous try-catch only guards postMessage's own throws (re-transfer after transfer ownership was already released, non-serializable objects inside transfers, etc.). Once postMessage returns, any exception thrown while the remote processes the action is in the other side's event loop and must come back through the callback ERROR channel;
  2. Callback messages must eventually be delivered — otherwise the caller's promise stays pending forever. This is why .finally blocks always .catch(() => {}) to claim rejections: in a destroy race, MessageHandler may be destroy()'d early and the callback is never received.

The four heavyweight calls in api.js (GetDocRequest / GetPage / GetMetadata / SaveDocument) all go through this channel.

sendWithStream(): The Third Shape, Most Complex

The streaming interface is wrapped in the web-standard ReadableStream. The caller receives a ReadableStream; the other end receives a sink object (with enqueue / close / error). Both sides coordinate backpressure through the 8 kinds of StreamKind messages inside #processStreamMessage.

Initiator: the ReadableStream's start / pull / cancel callbacks

sendWithStream(actionName, data, queueingStrategy, transfers) {
  const streamId = this.streamId++,
    sourceName = this.sourceName,
    targetName = this.targetName,
    comObj = this.comObj;
 
  return new ReadableStream({
    start: controller => {
      const startCapability = Promise.withResolvers();
      this.streamControllers[streamId] = {
        controller, startCall: startCapability,
        pullCall: null, cancelCall: null, isClosed: false,
      };
      comObj.postMessage({
        sourceName, targetName,
        action: actionName, streamId, data,
        desiredSize: controller.desiredSize, // initial backpressure info
      }, transfers);
      return startCapability.promise; // tells ReadableStream: wait on this promise before pulling
    },
    pull: controller => {
      const pullCapability = Promise.withResolvers();
      this.streamControllers[streamId].pullCall = pullCapability;
      comObj.postMessage({ sourceName, targetName,
        stream: StreamKind.PULL, streamId,
        desiredSize: controller.desiredSize });
      return pullCapability.promise; // tells ReadableStream: wait for PULL_COMPLETE before pulling again
    },
    cancel: reason => {
      assert(reason instanceof Error, "cancel must have a valid reason");
      const cancelCapability = Promise.withResolvers();
      this.streamControllers[streamId].cancelCall = cancelCapability;
      this.streamControllers[streamId].isClosed = true;
      comObj.postMessage({ sourceName, targetName,
        stream: StreamKind.CANCEL, streamId,
        reason: wrapReason(reason) });
      return cancelCapability.promise; // tells ReadableStream: wait for CANCEL_COMPLETE
    },
  }, queueingStrategy);
}

The start callback does not resolve directly — it first sends action + streamId + desiredSize to the peer, then returns startCapability.promise to the ReadableStream. Once the peer replies with START_COMPLETE (see #processStreamMessage below), startCall.resolve() is invoked, and only then does the ReadableStream begin pushing data to the consumer.

This is why in api.js, _pumpOperatorList can safely call reader.read() immediately after obtaining the readableStream — during start, the ReadableStream internally awaits, and does not accept pull requests until the remote sink is created and START_COMPLETE comes back.

pull fires whenever the ReadableStream's internal buffer has emptied enough that it needs more data. The next pull fires only after the returned promise resolves, so each pull round must wait for the peer's PULL_COMPLETE — this is the natural backpressure channel.

Receiver (peer side): #createStreamSink

The peer's MessageHandler discovers data.streamId present (not the stream field, but an action message carrying streamId) in #onMessage, and takes the #createStreamSink(data) path:

#createStreamSink(data) {
  const streamSink = {
    enqueue(chunk, size = 1, transfers) { /* see below */ },
    close()  { /* sends CLOSE */ },
    error(reason) { /* sends ERROR */ },
    sinkCapability: Promise.withResolvers(),
    onPull: null,   // triggered by PULL messages
    onCancel: null, // triggered by CANCEL messages
    isCancelled: false,
    desiredSize: data.desiredSize, // initial value from start message
    ready: null,
  };
  streamSink.sinkCapability.resolve();
  streamSink.ready = streamSink.sinkCapability.promise;
  this.streamSinks[streamId] = streamSink;
 
  Promise.try(action, data.data, streamSink).then(
    () => comObj.postMessage({ stream: StreamKind.START_COMPLETE, streamId, success: true }),
    reason => comObj.postMessage({ stream: StreamKind.START_COMPLETE, streamId, reason: wrapReason(reason) })
  );
}

The streamSink is the active producer — the sink's owner (e.g. the worker's GetOperatorList handler) pushes data to the peer via enqueue(chunk); the passively responsive ones are onPull / onCancel — when the ReadableStream side needs more data (or is cancelled), MessageHandler invokes these two callbacks.

#processStreamMessage: 8-Kind Bidirectional State Machine

All stream-control and data messages flow through this single switch:

case StreamKind.PULL:          // ReadableStream side needs more data → triggers sink.onPull
case StreamKind.PULL_COMPLETE: // sink.onPull finished → resolves the ReadableStream pull promise
case StreamKind.ENQUEUE:       // sink sent data → controller.enqueue
case StreamKind.CLOSE:         // sink ended → controller.close
case StreamKind.ERROR:         // sink errored → controller.error
case StreamKind.CANCEL:        // ReadableStream cancelled → triggers sink.onCancel
case StreamKind.CANCEL_COMPLETE: // sink.onCancel finished → resolves the ReadableStream cancel promise
case StreamKind.START_COMPLETE: // sink creation + action execution finished → resolves ReadableStream start promise

Think of it as two pairs of promise handshakes:

ReadableStream-side actionSends to peerPeer replies withThis side resolves
start()start message (with desiredSize)START_COMPLETEstartCall
pull()PULLPULL_COMPLETEpullCall
cancel()CANCELCANCEL_COMPLETEcancelCall
——ENQUEUE / CLOSE / ERRORcontroller operated directly, no promise

enqueue backpressure: bidirectional flow of desiredSize

There is a piece of non-obvious logic inside sink.enqueue:

const lastDesiredSize = this.desiredSize;
this.desiredSize -= size;
if (lastDesiredSize > 0 && this.desiredSize <= 0) {
  this.sinkCapability = Promise.withResolvers();
  this.ready = this.sinkCapability.promise;
}

desiredSize is the ReadableStream actively telling you "how much more I can take." Each time the sink enqueues a chunk, it subtracts the corresponding size; when the value drops to zero or below, the sink side suspends its own ready. This is a signal that "the producer can't outrun the consumer's pull" — the sink's owner (e.g. the worker's operatorList parsing loop) should await sink.ready before each enqueue; otherwise it blindly pushes until the peer's ReadableStream internal buffer bursts.

The matching trigger side lives inside the PULL message handler:

if (streamSink.desiredSize <= 0 && data.desiredSize > 0) {
  streamSink.sinkCapability.resolve();
}
streamSink.desiredSize = data.desiredSize;

When a peer pull arrives, it resets desiredSize to the new value (the ReadableStream should have consumed some data by then) and resolves the sink's suspended ready when the value transitions from non-positive to positive. desiredSize flows bidirectionally — decremented by sink.enqueue, reset on PULL arrival — forming the complete backpressure ring.

#deleteStreamController: wait for all three promises to settle before deleting

async #deleteStreamController(streamController, streamId) {
  await Promise.allSettled([
    streamController.startCall?.promise,
    streamController.pullCall?.promise,
    streamController.cancelCall?.promise,
  ]);
  delete this.streamControllers[streamId];
}

When the controller is triggered for deletion by any of CLOSE / ERROR / CANCEL_COMPLETE, it cannot be deleted immediately — the ReadableStream internally may still be waiting on pullCall.promise (it is queued for execution, not yet resolved). An immediate delete would make pullCall.resolve(...) throw TypeError: Cannot read properties of undefined. Promise.allSettled guarantees all three promises settle before cleanup, letting every pending callback inside the ReadableStream conclude safely.

#onMessage: The Three Shapes of the Dispatch Main Loop

#onMessage({ data }) {
  if (data.targetName !== this.sourceName) return; // soft route
 
  if (data.stream) {
    this.#processStreamMessage(data);   // StreamKind control or data
    return;
  }
  if (data.callback) {
    // pure reply: fetch capability from callbackCapabilities, resolve or reject by DATA/ERROR
    return;
  }
 
  // anything left is an action
  const action = this.actionHandler[data.action];
  if (!action) throw new Error(`Unknown action from worker: ${data.action}`);
 
  if (data.callbackId) {
    // action + callbackId: execute, send result/exception back as callback
    Promise.try(action, data.data).then(
      result => comObj.postMessage({ callback: DATA, callbackId, data: result }),
      reason => comObj.postMessage({ callback: ERROR, callbackId, reason: wrapReason(reason) })
    );
    return;
  }
  if (data.streamId) {
    // action + streamId: create sink, do not reply in action shape
    this.#createStreamSink(data);
    return;
  }
  action(data.data); // pure action, fire-and-forget
}

A few points worth expanding:

What Promise.try does: it wraps actions that might throw synchronously inside a microtask, so that subsequent .then/.catch can catch them. Without it, action(data.data) throwing synchronously would blow up in the current event loop instead of going through the ERROR callback path.

There are two callbackId replies: DATA and ERROR. Both sides use wrapReason to preserve Error types.

data.callbackId present but data.streamId absent → request-reply; data.streamId present → streaming; data.callback present → pure reply. The three are mutually exclusive. #onMessage distinguishes them purely by field presence, not by trial-and-error try/catch — this is an intentional design choice to avoid message-semantics ambiguity.

wrapReason: Preserving Error Identity Across Threads

postMessage does not pass Error instances as Errors — it serializes them into plain objects { name, message, ... }. On the receiving end, the name field must be used to reconstruct PDF.js's custom exception types:

function wrapReason(ex) {
  // already the correct type → return directly (avoid a second construction)
  if (ex instanceof AbortException || ex instanceof InvalidPDFException ||
      ex instanceof PasswordException || ex instanceof ResponseException ||
      ex instanceof UnknownErrorException) {
    return ex;
  }
  if (!(ex instanceof Error || (typeof ex === "object" && ex !== null))) {
    unreachable('wrapReason: Expected "reason" to be a (possibly cloned) Error.');
  }
  switch (ex.name) {
    case "AbortException":     return new AbortException(ex.message);
    case "InvalidPDFException": return new InvalidPDFException(ex.message);
    case "PasswordException":   return new PasswordException(ex.message, ex.code);
    case "ResponseException":   return new ResponseException(ex.message, ex.status, ex.missing);
    case "UnknownErrorException": return new UnknownErrorException(ex.message, ex.details);
  }
  return new UnknownErrorException(ex.message, ex.toString());
}

Five custom exception types, each with its own extra fields (PasswordException.code / ResponseException.status + missing / UnknownErrorException.details), all preserved verbatim by wrapReason. The worker side calls wrapReason once (on send), and the main-thread side calls wrapReason again (on receive) — but since it is already the correct instanceof type, the second wrap short-circuits and returns the original without re-constructing.

destroy(): End of Lifecycle

destroy() {
  this.#messageAC?.abort();
  this.#messageAC = null;
}

AbortController.abort() removes the message listener on comObj, so no matter how many messages comObj delivers afterwards, #onMessage will never fire again. The four tables (callbackCapabilities / streamControllers / streamSinks / actionHandler) are not explicitly cleared, but if the capability objects they hold are referenced externally, they enter a permanently pending state that is never resolved or rejected — which is exactly why api.js calls .catch(() => {}) to claim _capability.promise before destroy().

LoopbackPort: Isomorphic Communication Under the Fake-Worker Fallback

pdf.WorkerTransport uses LoopbackPort in _setupFakeWorker to stand in for a real MessagePort:

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);

The two MessageHandler instances share the same LoopbackPort — whichever side calls postMessage, the other side's listener is invoked immediately (synchronous dispatch). For MessageHandler, the LoopbackPort.addEventListener("message", cb) + postMessage(obj) interface is identical to a real MessagePort — so the MessageHandler layer is completely agnostic to whether it is dealing with same-thread or cross-thread communication.

This is precisely the "capability fallback that does not disturb upper layers" pattern mentioned in the api.js article: LoopbackPort only turns communication from "asynchronous postMessage" into "synchronous event firing"; all three MessageHandler APIs (send / sendWithPromise / sendWithStream) and all three tables' usage remain exactly the same.

Summary

MessageHandler is the minimal-primitive wrapper around main-thread↔worker communication in PDF.js — it carries no business semantics like "read a file" or "fetch a page"; it only establishes three message shapes and an envelope protocol.

Landing scenarios for the three communication shapes

ShapeCallerBusiness meaning
sendWorkerTransport.on("Ready", ...)Declaration: "my side is ready"
sendWithPromiseWorkerTransport._setupCapability"Please give me a receipt for GetDocRequest"
sendWithStreamPDFPageProxy._pumpOperatorList"Push the operatorList over in chunks"
streamSink.enqueueworker-side GetOperatorList handler"I just finished parsing one chunk of draw commands"

Design principles of the envelope protocol

  1. Soft route does not drop messages: sourceName / targetName mismatch only returns, never throws. In multi-document coexistence scenarios, a large number of messages are naturally filtered out.
  2. Requests must always reply with a callback: actions carrying callbackId must reply with a callback after execution, or the caller hangs. This is a contract, not a suggestion.
  3. Flow control is naturally built in: the ReadableStream's desiredSize flows across both sides via two postMessage calls (at start time and on every pull round). The sink side suspends ready accordingly, forming the complete backpressure ring.
  4. Error identity is preserved: wrapReason ensures PDF.js custom exceptions crossing thread boundaries remain the correct instanceof type on the other side.
  5. Isomorphic interface equals fallback capability: LoopbackPort and MessagePort have identical interfaces, making the fake-worker fallback transparent to MessageHandler.

Once you understand MessageHandler, the four heavyweight calls from the api.js articles (GetDocRequest / Ready / GetOperatorList / GetTextContent) cease to be "a vague sentence about worker communication" — you will know precisely which table callbackId goes into, which two tables streamId goes into, and how desiredSize flows back and forth.

The next installment should cover network streams (fetch_stream.js + network_stream.js) — GetReader / GetRangeReader messages run on MessageHandler's stream channel; MessageHandler is the foundation beneath them.

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