StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive 4: MessageHandler

October 22, 2026 · StreamPDF Team

PDF.js Deep Dive 4: MessageHandler

Introduction

In the previous three articles, we covered PDF.js's rendering architecture and the basics of worker communication. This time we go deep into the communication layer itself — the MessageHandler class that sits on both sides of the main-worker boundary and coordinates every message that flows between them.

All code in this article is based on PDF.js v6.3.289.

MessageHandler

Both the main thread and the worker thread use the MessageHandler class to exchange messages.

Thread-level wiring — one handler per worker:

// Source: display/api.js PDFWorker.#initialize
const worker = new Worker(workerSrc, { type: 'module' });
const messageHandler = new MessageHandler('main', 'worker', worker);
// Source: core/worker.js WorkMessageHandler.initializeFromPort
static initializeFromPort(port) {
  const handler = new MessageHandler("worker", "main", port);
  this.setup(handler, port);
  handler.send("ready", null);
}

Document-level wiring — one handler per document:

// Source: display/api.js getDocument
const messageHandler = new MessageHandler(docId, workerId, worker.port);
const transport = new WorkerTransport(
  messageHandler, task, networkStream,
  transportParams, transportFactory, pagesMapper,
);
// Source: core/worker.js WorkMessageHandler.createDocumentHandler
const workerHandlerName = docId + '_worker';
let handler = new MessageHandler(workerHandlerName, docId, port);

A MessageHandler exposes these methods:

  • constructor
  • #onMessage
  • on
  • send
  • sendWithPromise
  • sendWithStream
  • #createStreamSink
  • #processStreamMessage
  • #deleteStreamController

Let's walk through each one.

constructor

// Source: shared/message_handler.js MessageHandler
constructor(sourceName, targetName, comObj) {
  this.sourceName = sourceName;
  this.targetName = targetName;
  this.comObj = comObj;
  this.callbackId = 1;
  this.streamId = 1;
  this.streamSinks = Object.create(null);
  this.streamControllers = Object.create(null);
  this.callbackCapabilities = Object.create(null);
  this.actionHandler = Object.create(null);
 
  comObj.addEventListener("message", this.#onMessage.bind(this), {
    signal: this.#messageAC.signal,
  });
}

Three constructor arguments:

  • sourceName — name of this end (e.g. "main" or "worker"). Used for defensive dispatch: a message whose targetName doesn't match this.sourceName is simply dropped.
  • targetName — name of the remote end.
  • comObj — the communication channel. Usually a Worker or a MessagePort; both speak message / postMessage.

The signal option wires this listener into an AbortController so tearing down the MessageHandler cleanly removes the listener.

The class maintains three maps that back the three send methods:

FieldBacks
actionHandlerplain on / send (fire-and-forget)
callbackId + callbackCapabilitiessendWithPromise (request-response)
streamId + streamSinks + streamControllerssendWithStream (streaming + backpressure — the most complex of the three)

#onMessage

#onMessage({ data }) {
  if (data.targetName !== this.sourceName) {
    return;
  }
  if (data.stream) {
    this.#processStreamMessage(data);
    return;
  }
  if (data.callback) {
    const callbackId = data.callbackId;
    const capability = this.callbackCapabilities[callbackId];
    if (!capability) {
      throw new Error(`Cannot resolve callback ${callbackId}`);
    }
    delete this.callbackCapabilities[callbackId];
 
    if (data.callback === CallbackKind.DATA) {
      capability.resolve(data.data);
    } else if (data.callback === CallbackKind.ERROR) {
      capability.reject(wrapReason(data.reason));
    } else {
      throw new Error("Unexpected callback case");
    }
    return;
  }
  const action = this.actionHandler[data.action];
  if (!action) {
    throw new Error(`Unknown action from worker: ${data.action}`);
  }
  if (data.callbackId) {
    const sourceName = this.sourceName,
      targetName = data.sourceName,
      comObj = this.comObj;
 
    Promise.try(action, data.data).then(
      function (result) {
        comObj.postMessage({
          sourceName, targetName,
          callback: CallbackKind.DATA,
          callbackId: data.callbackId,
          data: result,
        });
      },
      function (reason) {
        comObj.postMessage({
          sourceName, targetName,
          callback: CallbackKind.ERROR,
          callbackId: data.callbackId,
          reason: wrapReason(reason),
        });
      }
    );
    return;
  }
  if (data.streamId) {
    this.#createStreamSink(data);
    return;
  }
  action(data.data);
}

After the targetName defensive check, the dispatches fall into four categories — mirroring the four message shapes produced by the three send methods:

TestProducerConsumer side effect
data.streamsendWithStream's pull / cancel#processStreamMessage handles PULL, ENQUEUE, CLOSE, ERROR, …
data.callbackremote end responding to sendWithPromiseresolve / reject the waiting Promise in callbackCapabilities
data.callbackIdremote end receiving sendWithPromiserun the handler with Promise.try, post the result back
data.streamIdremote end receiving sendWithStreamcreate a streamSink via #createStreamSink
defaultsendjust action(data.data) — no reply expected

on

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

Registers a handler under an action name. Under sendWithStream, the handler also receives the streamSink as a second argument — not obvious from this method alone, but important for the streaming case.

send

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

The fire-and-forget primitive. One-way, no callback, no stream.

Q: What is transfers?

It's the second argument to postMessage — a list of Transferable Objects (ArrayBuffer, TypedArray, MessagePort, ImageBitmap, OffscreenCanvas, …). The name is misleading: it doesn't select what data to send — it selects whose ownership to hand over to the other side.

Without transfers, postMessage uses the Structured Clone algorithm: the receiver gets a deep copy, cost proportional to size. With transfers, the bytes travel untouched, the sender's ArrayBuffer is immediately detached (byteLength becomes 0, further access throws). So it's only for data the sender truly no longer needs.

PDF.js produces huge amounts of such data: worker-decoded image pixels (imgData.data.buffer / imgData.bitmap), path geometry (data.buffer, minMax.buffer), font export buffers (fontData.buffer), and operator-list chunks collected into _transfers before each flush. From core/evaluator.js:

const transfers = imgData ? [imgData.bitmap || imgData.data.buffer] : null;
this.handler.send("commonobj", [objId, "Image", imgData], transfers);

Note data still references the same ArrayBuffer. The receiver dereferences it directly — zero-copy in action.

As a bonus, passing transfers also probes the runtime: if transferables aren't supported, postMessage throws DataCloneError. PDF.js pre-flights this in PDFWorker.#initialize with a dummy message and falls back to a fake worker if it fails.

send basic path

sendWithPromise

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

A send with a Promise attached. The trick is callbackId: each outgoing call gets a unique id, stored alongside a withResolvers() capability. The remote end runs the handler and posts back a message carrying callback: CallbackKind.DATA | ERROR plus the same callbackId. The sender's #onMessage finds the waiting capability, resolves or rejects it.

The message lifecycle:

  1. Caller sends { action, callbackId, data }
  2. Receiver: data.callbackId is truthy → Promise.try(action, data.data) → posts back { callback: DATA, callbackId, data }
  3. Caller: data.callback is truthy → resolves the Promise stored under data.callbackId

sendWithPromise handshake

sendWithStream

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,
          },
          transfers
        );
        return startCapability.promise;
      },
 
      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;
      },
 
      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;
      },
    },
    queueingStrategy
  );
}

Before we trace this method, let's recall what ReadableStream is and why PDF.js picks it here.

ReadableStream

Streams let you break a resource you want to fetch over the network into small chunks, then process those chunks. This is exactly how browsers handle video buffers and progressive image rendering. — MDN

The old all-at-once download has two problems:

  • Memory — loading a multi-GB binary into a single buffer blows up the heap.
  • Latency — a 100-second video cannot begin playback until the download finishes. The same applies to PDFs: loading the entire file before rendering means the first paint waits for all bytes.

Note: Non-linearized PDFs (xref table at the end) force Chrome PDFium into full-download mode too. Only linearized ("Fast Web View") PDFs stream progressively.

All-at-once vs streaming

ReadableStream takes a builder object with three callbacks:

CallbackWhen it firesResponsibility
start(controller)immediately on newInitialize resources; do not produce data
pull(controller)consumer calls read() and queue is emptyProduce a chunk or close the stream
cancel(reason)stream is cancelledClean up resources

Three usage patterns:

PatternProducer roleWhen production happensWhen pull firesTypical use case
start-onlyActive pushsetInterval / onmessage shoves chunks inNever (queue stays full)WebSocket / SSE push
pull-onlyPassive pullConsumer-drivenEvery read() when queue emptyInstantly-computable data, TransformStream readable side
start + pullWarm-up + on-demandstart initializes, pull producesEvery read() when queue emptyfetch(), DB pagination, pipe pipelines

Key invariants: enqueue() never blocks — it always returns synchronously. The queue length (the negative of desiredSize) is a suggestion, not a hard gate. pull only fires when the queue is empty and the consumer calls read() — that is natural backpressure. Default highWaterMark = 1, so desiredSize = highWaterMark - queue.length.

start + pull minimal example (the exact pattern PDF.js uses)
let pullCount = 0;
const stream = new ReadableStream({
  start(controller) {
    this.resource = { ready: true };   // "open a remote connection"
    console.log('start: channel ready');
  },
  pull(controller) {
    pullCount++;
    controller.enqueue(new Uint8Array([pullCount]));
    if (pullCount >= 5) controller.close();
  },
  cancel() { pullCount = 0; },
});
 
// start runs first (logs "channel ready"), then pull fires on read() when queue is empty
await stream.getReader().read();

💡 Try all three patterns live in the browser: start-only demo · pull-only demo · start + pull demo

The Worker-side counterpart to sendWithStream

sendWithStream on the Main side returns a standard ReadableStream. On the Worker side, #onMessage receives a message with streamId and dispatches to #createStreamSink, which builds a custom streamSink object — the mirror of the Main-side controller but for the producer role.

#createStreamSink(data) {
  const streamId = data.streamId,
    sourceName = this.sourceName,
    targetName = data.sourceName,
    comObj = this.comObj;
  const self = this,
    action = this.actionHandler[data.action];
 
  const streamSink = {
    enqueue(chunk, size = 1, transfers) {
      if (this.isCancelled) return;
      const lastDesiredSize = this.desiredSize;
      this.desiredSize -= size;
      // When desiredSize crosses from positive to non-positive,
      // we create an unresolved Promise that the producer will await.
      if (lastDesiredSize > 0 && this.desiredSize <= 0) {
        this.sinkCapability = Promise.withResolvers();
        this.ready = this.sinkCapability.promise;
      }
      comObj.postMessage(
        {
          sourceName, targetName,
          stream: StreamKind.ENQUEUE,
          streamId, chunk,
        },
        transfers
      );
    },
 
    close() {
      if (this.isCancelled) return;
      this.isCancelled = true;
      comObj.postMessage({
        sourceName, targetName,
        stream: StreamKind.CLOSE,
        streamId,
      });
      delete self.streamSinks[streamId];
    },
 
    error(reason) {
      assert(reason instanceof Error, "error must have a valid reason");
      if (this.isCancelled) return;
      this.isCancelled = true;
      comObj.postMessage({
        sourceName, targetName,
        stream: StreamKind.ERROR,
        streamId,
        reason: wrapReason(reason),
      });
    },
 
    sinkCapability: Promise.withResolvers(),
    onPull: null,
    onCancel: null,
    isCancelled: false,
    desiredSize: data.desiredSize,
    ready: null,
  };
 
  streamSink.sinkCapability.resolve();
  streamSink.ready = streamSink.sinkCapability.promise;
  this.streamSinks[streamId] = streamSink;
 
  Promise.try(action, data.data, streamSink).then(
    function () {
      comObj.postMessage({
        sourceName, targetName,
        stream: StreamKind.START_COMPLETE,
        streamId, success: true,
      });
    },
    function (reason) {
      comObj.postMessage({
        sourceName, targetName,
        stream: StreamKind.START_COMPLETE,
        streamId, reason: wrapReason(reason),
      });
    }
  );
}

The handler registered with on is invoked with (data.data, streamSink). Once it resolves, the Worker posts START_COMPLETE back, letting the Main side's start() Promise settle.

Example: GetOperatorList

Main side — PDFPageProxy._pumpOperatorList:

const readableStream = this._transport.messageHandler.sendWithStream(
  'GetOperatorList',
  {
    pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
    pageIndex: this._pageIndex,
    intent: renderingIntent,
    cacheKey, annotationStorage: map, modifiedIds,
  },
  /* queueingStrategy = */ undefined,
  transfer,
);
const reader = readableStream.getReader();
 
const intentState = this._intentStates.get(cacheKey);
intentState.streamReader = reader;
 
const pump = () => {
  reader.read().then(
    ({ value, done }) => {
      if (done) {
        intentState.streamReader = null;
        return;
      }
      if (this._transport.destroyed) return;
      this._renderPageChunk(value, intentState);
      pump();
    },
    reason => {
      intentState.streamReader = null;
      if (this._transport.destroyed) return;
      if (intentState.operatorList) {
        intentState.operatorList.lastChunk = true;
        for (const internalRenderTask of intentState.renderTasks) {
          internalRenderTask.operatorListChanged();
        }
        this.#tryCleanup();
      }
      if (intentState.displayReadyCapability) {
        intentState.displayReadyCapability.reject(reason);
      } else if (intentState.opListReadCapability) {
        intentState.opListReadCapability.reject(reason);
      } else {
        throw reason;
      }
    },
  );
};
pump();

Worker side — handler registered in WorkMessageHandler.createDocumentHandler:

handler.on(
  'GetOperatorList',
  function ({ pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds }, sink) {
    pdfManager.getPage(pageId).then(function (page) {
      const task = new WorkerTask(`GetOperatorList: page ${pageIndex}`);
      startWorkerTask(task);
 
      const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0;
 
      page
        .getOperatorList({
          handler, sink, task, intent, cacheKey,
          annotationStorage, modifiedIds, pageIndex,
        })
        .then(
          opListInfo => {
            if (start) info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
            sink.close();
          },
          reason => {
            if (task.terminated) return;
            sink.error(reason);
          },
        )
        .finally(() => finishWorkerTask(task));
    });
  },
);

The handler doesn't return anything, so Promise.try(...) resolves immediately and #createStreamSink posts START_COMPLETE.

Back on Main: #processStreamMessage

#processStreamMessage(data) {
  const streamId = data.streamId,
    sourceName = this.sourceName,
    targetName = data.sourceName,
    comObj = this.comObj;
  const streamController = this.streamControllers[streamId],
    streamSink = this.streamSinks[streamId];
 
  switch (data.stream) {
    case StreamKind.START_COMPLETE:
      if (data.success) {
        streamController.startCall.resolve();
      } else {
        streamController.startCall.reject(wrapReason(data.reason));
      }
      break;
 
    case StreamKind.PULL_COMPLETE:
      if (data.success) {
        streamController.pullCall.resolve();
      } else {
        streamController.pullCall.reject(wrapReason(data.reason));
      }
      break;
 
    case StreamKind.PULL:
      if (!streamSink) {
        comObj.postMessage({ stream: StreamKind.PULL_COMPLETE, success: true });
        break;
      }
      // desiredSize goes from negative to positive → resolve sink.ready,
      // releasing the Worker-side backpressure
      if (streamSink.desiredSize <= 0 && data.desiredSize > 0) {
        streamSink.sinkCapability.resolve();
      }
      streamSink.desiredSize = data.desiredSize;
      Promise.try(streamSink.onPull).then(
        () => comObj.postMessage({ stream: StreamKind.PULL_COMPLETE, success: true }),
        reason => comObj.postMessage({ stream: StreamKind.PULL_COMPLETE, reason })
      );
      break;
 
    case StreamKind.ENQUEUE:
      // Worker produced a chunk → stuff it into the ReadableStream internal queue
      if (streamController.isClosed) break;
      streamController.controller.enqueue(data.chunk);
      break;
 
    case StreamKind.CLOSE:
      streamController.isClosed = true;
      streamController.controller.close();
      this.#deleteStreamController(streamController, streamId);
      break;
 
    case StreamKind.ERROR:
      streamController.controller.error(wrapReason(data.reason));
      this.#deleteStreamController(streamController, streamId);
      break;
 
    default:
      throw new Error("Unexpected stream case");
  }
}

START_COMPLETE resolves the start() Promise. Then the pump() loop runs:

reader.read()        queue empty → triggers pull callback
  → postMessage(PULL, desiredSize)
                         ↓
          Worker: onPull → produces chunk(s)
                         ↓
          postMessage(ENQUEUE) or (CLOSE)
                         ↓
          Main: controller.enqueue(chunk)
                         ↓
reader.read() resolves → _renderPageChunk → pump()  (recursion)
                         ↓
          repeats until CLOSE → done: true

Backpressure in action: every sink.enqueue(chunk, size) on the Worker side decrements desiredSize. When it crosses from positive to non-positive, sink.ready becomes an unresolved Promise. The Worker's parsing loop await sink.ready — not a spin-wait, not a busy loop — just suspends the async function. When the Main side drains its queue and sends the next PULL with a fresh desiredSize, the Worker resolves sink.ready and resumes. Both sides run at the consumer's pace; memory stays bounded.

sendWithStream full sequence

Summary

PDF.js's MessageHandler layers three communication abstractions over the same postMessage primitive:

MethodPatternUse caseMessage shape
sendFire-and-forgetNotifications (ready, close, commonobj)One-way, no reply
sendWithPromiseRequest-responseOne-shot async queries (getPage, getMetadata)callbackId pairs the call with exactly one reply
sendWithStreamStreaming + backpressureLarge continuous data (operatorList, images, fonts)streamId ties a sequence of PULL/ENQUEUE/CLOSE messages together

The progression is deliberate:

  • sendWithPromise is send plus a callbackId — it pairs a Promise's resolve/reject with a message round-trip.
  • sendWithStream is send plus start/pull — the Main side gets a real ReadableStream, while the Worker side gets a custom streamSink. The interfaces are asymmetric, but postMessage messages tagged with a stream field (PULL, ENQUEUE, CLOSE, ERROR, PULL_COMPLETE, START_COMPLETE, CANCEL, CANCEL_COMPLETE) keep them coordinated.

sendWithStream is the article's centerpiece — it is the reason PDF.js can parse and render concurrently with bounded memory. The implementation is a textbook ReadableStream:

  • start sends START and waits for START_COMPLETE — the Main side's start() Promise settles
  • pull sends PULL; the Worker's streamSink.onPull produces data and posts ENQUEUE — no read() on Main means no data produced on Worker
  • Backpressure is Worker-local: sink.enqueue decrements desiredSize; crossing non-positive hangs sink.ready; the Worker parsing loop await ready; the next PULL with a larger desiredSize resolves it

What makes this elegant is that the Worker never blocks the JS thread. It doesn't spin on while (desiredSize <= 0) — it suspends an async function with a Promise. The Main and Worker both process other messages concurrently; backpressure just politely asks the Worker to "take a breather".

And the entire contract is just one number (desiredSize) travelling on each side of a postMessage, plus one Promise whose resolve/reject signals how much the consumer wants right now.

Related articles