StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread Communication

October 27, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread Communication

Introduction

The summary of Article 2 listed four "heavyweight players"; this article tackles the second: MessageHandler's messaging mechanism.

Across the previous five articles' demos, MessageHandler has appeared four times, each with a different call shape:

CallAppears inShapePayload
messageHandler.send("Ready", null)Article 2Pure actionDeclares the end of the handshake
worker.messageHandler.sendWithPromise("GetDocRequest", docParams)Article 2Action + callbackIdCross-thread parameter bundle
messageHandler.sendWithPromise("GetPage", { pageIndex: 0 })Article 3Action + callbackIdPage number request
messageHandler.sendWithStream("GetOperatorList", { pageId: 0, ... })Article 5Action + streamIdStreaming operatorList

The first two are both "one-shot request, one-shot reply"; the fourth is a streaming channel where the producer pushes in chunks and the consumer pulls by backpressure. This article picks up from where Article 2 creates the handler, walks through these four calls, and traces MessageHandler's internal envelope protocol and the full flow of all three shapes.

If you want to read both the caller's perspective (as used in the api.js articles) and the protocol implementation perspective (this article), cross-reference with the MessageHandler source code walkthrough. This article does not repeat the API list from that walkthrough — it focuses only on the actual execution path that happens in the demo.

Creation: Right After the Handshake Receives workerId

At Article 2, line 261, the worker replies to the GetDocRequest handshake with its workerId. The main thread immediately creates a MessageHandler:

const messageHandler = new MessageHandler(docId, workerId, worker.port);
// docId = "d1"
// workerId = id self-assigned by the worker (typically "worker" or a similar string in demos)
// worker.port = the Worker object itself (Worker is also a MessagePort-like)

The three parameters are stored into instance fields:

this.sourceName = "d1";       // Who am I
this.targetName = workerId;   // Who am I talking to
this.comObj     = worker.port; // Where do I send messages
this.callbackId = 1;          // callbackId for the next request
this.streamId   = 1;          // streamId for the next streaming channel
this.callbackCapabilities = {}; // callbackId → Promise.withResolvers()
this.actionHandler        = {}; // actionName → handler (WorkerTransport registers these later)
this.streamControllers    = {}; // streamId → ReadableStream controller (streams I initiate)
this.streamSinks          = {}; // streamId → sink (streams initiated by the peer, I am the producer)
 
comObj.addEventListener("message", this.#onMessage.bind(this), { signal: this.#messageAC.signal });

The constructor does just two things: builds four empty maps, and binds #onMessage into the worker.port's message event. At this point actionHandler is still empty — all business handlers are registered later by WorkerTransport.setupMessageHandler() via .on(...).

Design note: MessageHandler itself has zero knowledge of business semantics. It only recognizes three key fields of the "envelope" (action, callbackId, streamId) — it does not know GetDocRequest or GetPage as action names. All business semantics live in WorkerTransport's .on(...) registry. This makes MessageHandler reusable across any two endpoints: main thread ↔ worker, main thread ↔ main thread (LoopbackPort), and even future main thread ↔ iframe scenarios.

The Envelope Protocol: Three Mutually Exclusive Shapes

Every message that crosses comObj.postMessage is wrapped in the same envelope. On top of the envelope, messages are dispatched by shape based on four key fields: stream / callback / callbackId / streamId:

#onMessage({ data }) {
  if (data.targetName !== this.sourceName) return;
 
  if (data.stream)   { /* Stream control or stream data: PULL/ENQUEUE/CLOSE/... */ }
  if (data.callback) { /* Pure reply: peer gives me an answer, carries callbackId */ }
 
  // The rest are actions
  const action = this.actionHandler[data.action];
  if (data.callbackId) { /* action + callbackId: must send a callback reply after execution */ }
  else if (data.streamId) { /* action + streamId: create a sink after execution, no action-shape reply */ }
  else { /* pure action, fire-and-forget */ }
}

Strict order: stream → callback → action, mutually exclusive — a single message takes exactly one path. If two fields appear simultaneously (e.g., both stream and callback), the envelope was assembled incorrectly, and #onMessage will miss one of them.

sourceName / targetName are soft routing: if they don't match, the function simply returns without throwing. The demo has only one document (d1 ↔ worker), so every message matches; if you run two documents simultaneously, the second document's d2 MessageHandler will automatically ignore d1's messages — no extra isolation logic required.

Shape 1: Pure Action — the Ready Announcement

The final step of the handshake in Article 2:

messageHandler.send("Ready", null);

send is the simplest path:

send(actionName, data, transfers) {
  this.comObj.postMessage({
    sourceName: this.sourceName,  // "d1"
    targetName: this.targetName,  // "worker"
    action: actionName,           // "Ready"
    data,                         // null
  }, transfers);                  // undefined
}

The envelope has no callbackId, no streamId, and no stream field. When the peer worker's MessageHandler receives it, #onMessage falls through to the final else branch and directly invokes this.actionHandler["Ready"]. The worker side has registered this handler, which does things like "mark myself as ready". When it's done, it's done — no reply is sent. This is fire-and-forget.

This message is easy to miss in the demo because it does not affect any promise or return value. But WorkerTransport registers a large number of callbackId-carrying action handlers in setupMessageHandler (GetDoc / GetPage / SaveDocument, etc.) — all request-reply channels live there. send is only used for "notification" messages: Ready, configure(verbosity), and later Terminate.

Shape 2: Action + callbackId — GetPage to Fetch the First Page

The demo's canonical request-reply occurs in Article 3's pdf.getPage(1):

const promise = messageHandler
  .sendWithPromise("GetPage", { pageIndex: 0 });

The sending side of sendWithPromise

sendWithPromise(actionName, data, transfers) {
  const callbackId = this.callbackId++;  // 1, increments on each request
  const capability = Promise.withResolvers();
  this.callbackCapabilities[callbackId] = capability;
  try {
    comObj.postMessage({
      sourceName: "d1", targetName: "worker",
      action: "GetPage", callbackId: 1,
      data: { pageIndex: 0 },
    }, transfers);
  } catch (ex) {
    capability.reject(ex);
  }
  return capability.promise;
}

The only difference from send: it carries a callbackId, and stores the capability resolver in the this.callbackCapabilities[1] table. After postMessage returns, the promise is still pending — it must wait for the worker to send a callback-shape message back to resolve it.

The envelope has no streamId, so the peer worker's #onMessage will not take the streaming branch. It takes the action branch, finds that the envelope carries callbackId, and handles it as "must send a reply after execution".

Peer-side handling: the semantics of action + callbackId

The worker-side MessageHandler's #onMessage receives the envelope:

const action = this.actionHandler["GetPage"]; // handler registered on the worker side
if (data.callbackId) {
  Promise.try(action, data.data).then(
    result => comObj.postMessage({
      sourceName: "worker", targetName: "d1",
      callback: CallbackKind.DATA,
      callbackId: 1,
      data: result, // parsed pageInfo
    }),
    reason => comObj.postMessage({
      sourceName: "worker", targetName: "d1",
      callback: CallbackKind.ERROR,
      callbackId: 1,
      reason: wrapReason(reason),
    })
  );
  return;
}

The action field no longer appears in the reply — the reply is pure callback shape, carrying only callback (DATA=1 or ERROR=2), callbackId, and data or reason.

Back on the main thread: the callback branch resolves the pending promise

The main thread's #onMessage receives this reply and takes the callback branch:

if (data.callback) {
  const callbackId = data.callbackId; // 1
  const capability = this.callbackCapabilities[callbackId]; // exactly the one stored earlier by sendWithPromise
  delete this.callbackCapabilities[callbackId];
 
  if (data.callback === CallbackKind.DATA) {
    capability.resolve(data.data); // the promise resolves with pageInfo
  } else if (data.callback === CallbackKind.ERROR) {
    capability.reject(wrapReason(data.reason));
  }
}

The Promise returns to the main thread's event loop; the await promise in the demo's subsequent code receives pageInfo and constructs a PDFPageProxy to return.

What sendWithPromise's synchronous try-catch covers

try {
  comObj.postMessage({ /* ... */ }, transfers);
} catch (ex) {
  capability.reject(ex);
}

It only catches errors thrown by postMessage itself: for example, passing an ArrayBuffer whose ownership has already been transferred, or a non-serializable object in data. Once postMessage returns, any exception thrown while the worker-side handler is executing is already in another event loop — it must come back through the callback ERROR channel, which the try-catch cannot reach.

This is why this.callbackCapabilities[callbackId] = capability must store first, then postMessage: if postMessage throws, the catch block can immediately find the capability and reject it, leaving no dangling promise.

Shape 3: Action + streamId — GetOperatorList Streaming

The demo's most complex channel appears in Article 5's page.render(...), which triggers _pumpOperatorList:

const readableStream = messageHandler.sendWithStream(
  "GetOperatorList",
  { pageId: 0, pageIndex: 0, intent: 2, cacheKey: "2__", ... },
  undefined,  // queueingStrategy
  transfer
);
const reader = readableStream.getReader();
reader.read().then(({ value, done }) => { /* append into operatorList */ });

sendWithStream: the ReadableStream start callback as a "channel creation request"

sendWithStream(actionName, data, queueingStrategy, transfers) {
  const streamId = this.streamId++; // 1
  return new ReadableStream({
    start: controller => {
      const startCapability = Promise.withResolvers();
      this.streamControllers[streamId] = {
        controller, startCall: startCapability,
        pullCall: null, cancelCall: null, isClosed: false,
      };
      comObj.postMessage({
        sourceName: "d1", targetName: "worker",
        action: "GetOperatorList", streamId: 1,
        data: { pageId: 0, pageIndex: 0, intent: 2, cacheKey: "2__" },
        desiredSize: controller.desiredSize, // initial value; ReadableStream tells you "how much more can I accept"
      }, transfers);
      return startCapability.promise;
    },
    // pull / cancel see below
  }, queueingStrategy);
}

The key insight: the start callback returns a promise. ReadableStream will wait for this promise to resolve before it begins pushing data to the consumer. So the main thread first sends streamId=1 + action to the worker inside start, then returns startCapability.promise to ReadableStream. When the worker replies with START_COMPLETE, this promise resolves, and ReadableStream begins accepting pull requests.

The envelope carries streamId but no stream field — it is action + streamId shape, not stream-control shape.

Peer side: action + streamId → #createStreamSink

The worker-side MessageHandler's #onMessage sees data.streamId = 1 and calls this.#createStreamSink(data):

// Create a sink object for streamId=1:
const streamSink = {
  enqueue(chunk, size = 1, transfers) { /* send ENQUEUE */ },
  close()                             { /* send CLOSE   */ },
  error(reason)                       { /* send ERROR   */ },
  sinkCapability: Promise.withResolvers(),
  onPull:   null, // callback when a main-thread pull arrives
  onCancel: null, // callback when a main-thread cancel arrives
  isCancelled: false,
  desiredSize: data.desiredSize, // initial value taken from the start message
  ready: null,   // suspends when desiredSize <= 0; resolves when a pull arrives
};
this.streamSinks[1] = streamSink;
 
Promise.try(action, data.data, streamSink).then(
  () => comObj.postMessage({ stream: START_COMPLETE, streamId: 1, success: true }),
  reason => comObj.postMessage({ stream: START_COMPLETE, streamId: 1, reason: wrapReason(reason) })
);

The sink is an active producer: after the worker-side GetOperatorList handler receives the sink, it calls sink.enqueue(chunk) to push data to the main thread every time it finishes parsing a chunk of drawing operators; when all chunks are parsed, it calls sink.close(). The corresponding onPull / onCancel are passive responses — when the main thread's ReadableStream needs more data or gets cancelled, MessageHandler will call back these two functions.

The ready field is the sink-side backpressure signal: sink.desiredSize is what the main-thread ReadableStream tells the sink "how much more can I accept", decremented on each enqueue; when it drops to ≤ 0, ready is suspended, and the worker side should await sink.ready before each enqueue to avoid blindly pushing and overflowing the peer's buffer.

Main thread: START_COMPLETE → startCapability resolve

The worker replies with START_COMPLETE; the main thread's #processStreamMessage handles it:

case StreamKind.START_COMPLETE:
  if (data.success) {
    streamController.startCall.resolve();
  } else {
    streamController.startCall.reject(wrapReason(data.reason));
  }
  break;

startCapability resolves → ReadableStream internally considers start complete → begins pushing data to the consumer. When reader.read() is called for the first time in the demo, ReadableStream's internal buffer is empty → ReadableStream triggers its own pull callback → sends a PULL message.

The pull chain: main thread pulls → worker pushes → ENQUEUE back → ReadableStream controller.enqueue

Main thread ReadableStream controller needs more data
  → triggers pull() callback
  → pull callback inside sendWithStream sends PULL message to worker
  → worker #processStreamMessage handles PULL
    → resets sink.desiredSize = data.desiredSize
    → calls sink.onPull  (or the default no-op)
    → replies PULL_COMPLETE
  → main thread receives PULL_COMPLETE
    → streamController.pullCall.resolve
    → ReadableStream starts pulling data from worker
  → worker GetOperatorList handler finishes parsing a chunk
    → sink.enqueue(chunk)
    → sends ENQUEUE message
  → main thread #processStreamMessage handles ENQUEUE
    → streamController.controller.enqueue(data.chunk)

Each round: pull → several enqueues → close. ReadableStream's internal desiredSize updates automatically as the consumer reads, and the next pull carries the new value.

The cancel chain: main thread cancel → worker CANCEL → CANCEL_COMPLETE

The "100ms delayed cancellation" mentioned in Article 5 eventually calls readableStream.cancel(reason):

demo: streamReader.cancel(renderingCancelledException)
  → ReadableStream triggers cancel() callback
  → cancel callback inside sendWithStream sends CANCEL message
  → worker #processStreamMessage handles CANCEL
    → calls sink.onCancel(reason)
    → replies CANCEL_COMPLETE
    → sink.isCancelled = true
    → sink.sinkCapability.reject(reason)
  → main thread receives CANCEL_COMPLETE
    → streamController.cancelCall.resolve
    → #deleteStreamController cleans up after the three promises settle

The sink side must also clean up synchronously: sink.isCancelled = true turns subsequent sink.enqueue / sink.close / sink.error into no-ops — preventing the worker from continuing to pump data into an already-cancelled channel.

Locations of all 8 StreamKind values

StreamKindSenderReceiverMeaning
START_COMPLETEsinkcontrollersink created, action finished executing
PULLcontrollersink"I need more data"
PULL_COMPLETEsinkcontroller"I've responded to your pull"
ENQUEUEsinkcontroller"Here's a chunk of data"
CLOSEsinkcontroller"No more data"
ERRORsinkcontroller"Something went wrong"
CANCELcontrollersink"Channel cancelled, stop pushing"
CANCEL_COMPLETEsinkcontroller"I've responded to your cancellation"

Demo Call Cross-Reference Table by Shape

Demo callEnvelope shapeMain channel fieldWhat the peer must reply
send("Ready", null)Pure actionaction = "Ready"No reply
sendWithPromise("GetPage", ...)Action + callbackIdcallbackId = 1callback (DATA or ERROR)
sendWithStream("GetOperatorList", ...)Action + streamIdstreamId = 1 + desiredSizeSTART_COMPLETE → ENQUEUE × N → CLOSE (or CANCEL)

The three channels do not interfere: the same comObj (worker.port) carries all three message streams simultaneously, routing to the correct table entry via the callbackId / streamId fields. Any channel's message carries sourceName / targetName scoped precisely to the document (d1 ↔ worker), so multiple coexisting documents are automatically isolated.

Hook: WorkerTransport Is the Real "Semantic Layer"

Right after MessageHandler is created in Article 2, the WorkerTransport constructor calls messageHandler.on("GetDoc", handler), .on("DocException", ...), and about 20 more registrations — these handlers are the actual "business" that runs on the other side of MessageHandler. MessageHandler itself doesn't know what differentiates GetDoc from GetPage; it only knows "the envelope says there's an action, go look it up in actionHandler".

This division means: any custom action can be plugged in, as long as both sides register handlers. Beyond pdf.js's built-in 20+ actions, user-defined extensions (Stamp / FreeText, etc. in the editor) also flow through the same channel — MessageHandler does not distinguish between built-in and extension actions.

Summary

This article followed three concrete calls in the demo and traced MessageHandler's full internal flow:

  1. The envelope protocol: four key fields (action / callbackId / streamId / stream) determine which path a message takes, with sourceName/targetName providing soft routing;
  2. Pure action (send): the Ready announcement, no reply sent;
  3. Action + callbackId (sendWithPromise): the GetPage request; the worker executes and replies with a callback (DATA or ERROR), and the main thread resolves the pending promise from the callbackCapabilities table;
  4. Action + streamId (sendWithStream): the GetOperatorList streaming channel; ReadableStream's start/pull/cancel callbacks pair up with the peer's sink, and 8 StreamKind values form a bidirectional backpressure state machine.

The three channels do not interfere with each other; the same comObj carries them all simultaneously, with precise routing via callbackId/streamId. MessageHandler itself has no business semantics — the meaning of every action lives entirely in WorkerTransport's .on(...) registry. It is a minimal primitive encapsulation.

Want to see the source-level API list and implementation details at the same time? Cross-reference with the MessageHandler source code walkthrough. Want to move to the next demo hook? Go back to Article 2's list of four heavyweight players — next up is WorkerTransport initialization (the block in Article 2, step 4, that immediately follows MessageHandler creation).

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