StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive 5: PDFFetchStream

October 25, 2026 · StreamPDF Team

PDF.js Deep Dive 5: PDFFetchStream

Introduction

This is the fifth article in our PDF.js series. It digs into the PDFFetchStream class: how it pulls a PDF's binary data off the network, and how the full request relates to byte-range requests.

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

PDFFetchStream fetches a PDF's bytes over the network: both the full request and Range requests, implemented by PDFFetchStreamReader and PDFFetchStreamRangeReader respectively.

The article answers three questions in order:

  1. Why does PDF.js sometimes cancel a 200 request right after issuing it?
  2. Who triggers a Range request, and why does it have to cross both the worker and the main thread?
  3. Why is Range only possible when Content-Encoding is identity?

The PDFFetchStream class

class PDFFetchStream extends BasePDFStream {
  _responseOrigin = null;
 
  constructor(source) {
    super(source, PDFFetchStreamReader, PDFFetchStreamRangeReader);
    const { httpHeaders, url } = source;
 
    assert(/https?:/.test(url.protocol), 'PDFFetchStream only supports http(s):// URLs.');
    this.headers = createHeaders(/* isHttp = */ true, httpHeaders);
  }
}

The class itself is trivial: it calls the parent constructor and hands it PDFFetchStreamReader and PDFFetchStreamRangeReader.

In the parent, BasePDFStream:

constructor(source, PDFStreamReader, PDFStreamRangeReader) {
  if (
    (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
    this.constructor === BasePDFStream
  ) {
    unreachable("Cannot initialize BasePDFStream.");
  }
  this._source = source;
 
  this.#PDFStreamReader = PDFStreamReader;
  this.#PDFStreamRangeReader = PDFStreamRangeReader;
}

It stores source, PDFStreamReader and PDFStreamRangeReader.

As we saw in the getPdfManager part of the second article, getFullReader is what gets called:

getFullReader() {
  assert(
    !this._fullReader,
    "BasePDFStream.getFullReader can only be called once."
  );
  return (this._fullReader = new this.#PDFStreamReader(this));
}

That instantiates this.#PDFStreamReader, which points to the PDFFetchStreamReader class.

The PDFFetchStreamReader class

Entering PDFFetchStreamReader.constructor

constructor(stream) {
  super(stream);
  const {
    disableRange,
    disableStream,
    rangeChunkSize,
    url,
    withCredentials,
  } = stream._source;
 
  this._isStreamingSupported = !disableStream;
  // Always create a copy of the headers.
  const headers = new Headers(stream.headers);
 
  fetchUrl(url, headers, withCredentials, this._abortController)
    .then(response => {
      stream._responseOrigin = getResponseOrigin(response.url);
 
      ensureResponseStatus(response.status, url);
      this._reader = response.body.getReader();
 
      const responseHeaders = response.headers;
 
      const { contentLength, isRangeSupported } =
        validateRangeRequestCapabilities({
          responseHeaders,
          isHttp: true,
          rangeChunkSize,
          disableRange,
        });
      this._contentLength = contentLength;
      this._isRangeSupported = isRangeSupported;
 
      this._filename = extractFilenameFromHeader(responseHeaders);
 
      // We need to stop reading when range is supported and streaming is
      // disabled.
      if (!this._isStreamingSupported && this._isRangeSupported) {
        this.cancel(new AbortException("Streaming is disabled."));
      }
 
      this._headersCapability.resolve();
    })
    .catch(this._headersCapability.reject);
}

The constructor calls fetchUrl,

Entering PDFFetchStreamReader.constructor -> fetchUrl(...)

function fetchUrl(url, headers, withCredentials, abortController) {
  return fetch(url, {
    method: 'GET',
    headers,
    signal: abortController.signal,
    mode: 'cors',
    credentials: withCredentials ? 'include' : 'same-origin',
    redirect: 'follow',
  });
}

which calls the browser's fetch to get the data.

Note that fetch does not wait for the whole body before it resolves.

This is the key to how PDF.js loads files over the network. The promise returned by fetch() resolves as soon as the response headers arrive, not when the entire body has been downloaded. The body is exposed as a ReadableStream on response.body and can be read chunk by chunk.

That means:

  1. PDF.js can read Content-Length, Accept-Ranges and other key metadata while the download has barely started, without waiting for the whole file;
  2. response.body.getReader() lets it parse while downloading — progressive rendering. Parsing can start on the first page's bytes because the parser fetches the xref at the tail of the file with a Range request up front instead of waiting for the download to reach it sequentially;
  3. If the server supports byte ranges, PDF.js can cut the full stream and switch to Range headers, requesting only the byte ranges it needs and cutting time-to-first-page even further.

This is why DevTools sometimes shows a 200 (canceled) with only response headers and no body: PDF.js cancels the full request as soon as it has the headers and asks for the byte ranges it needs instead.

There is one common misunderstanding here: the full stream is not always cancelled. Whether it is depends on disableStream (default false):

ModedisableStreamFull streamWhat Range is for
Streaming (default)falseDownloads to the end, feeding ChunkedStreamFills gaps the parser outran (e.g. xref at the tail)
Streaming disabledtruecancel()ed right after the headersCarries all the data — the 200 (canceled) case

Without this property, the "instant open" experience PDF.js is known for would not be possible.

A minimal demo

The snippet below reproduces what PDF.js does: it starts a full GET, cancels it as soon as the headers arrive, and then switches to Range requests.

const PDF_URL = 'https://assets.streampdf.com/PDF32000_2008.pdf'; // 22.5 MB, supports Range
 
async function demo() {
  const controller = new AbortController();
 
  // 1. Start the full request: once headers arrive, fetch resolves
  const response = await fetch(PDF_URL, { signal: controller.signal });
  console.log('status:', response.status); // 200, body may still be streaming
  const fileSize = Number(response.headers.get('Content-Length'));
  console.log('content-length:', fileSize);
 
  // 2. Grab the streaming reader for the body
  const reader = response.body.getReader();
 
  // 3. Read just one chunk, mimicking a parser that finds out it still
  //    needs the xref at the tail of the file
  const { value } = await reader.read();
  console.log('bytes actually read:', value.byteLength);
 
  // 4. Cancel
  await reader.cancel(); // closes the stream, the browser drops the connection
  controller.abort(); // belt and braces: put the signal into aborted state
 
  // 5. Switch to a Range request for the last 1 KB
  const rangeResp = await fetch(PDF_URL, {
    headers: { Range: `bytes=${fileSize - 1024}-${fileSize - 1}` },
  });
  console.log('range status:', rangeResp.status); // 206 Partial Content
}

Live demo (with logs and a DevTools walkthrough): fetch-abort-demo

The two cancellation calls are not the same thing:

  • reader.cancel() closes the data stream. The fetch promise has long since resolved, so nothing rejects; the browser simply stops receiving the remaining bytes and closes the connection.
  • controller.abort() aborts the whole request. If the fetch promise has not resolved yet (headers have not arrived), it rejects with AbortError.

As an aside, PDF.js calls both, in that order: this._reader?.cancel(reason) followed immediately by this._abortController.abort(). Both are needed because cancel() can fire before _reader has been assigned (headers not yet received); in that window abort() is the only way out.

Watching it in DevTools

Open the Network panel and load a large PDF that supports Range with disableStream: true. You will see two entries:

RequestStatusSizeWhat it is
demo.pdf200 + (canceled)stops after a few dozen KBfull request, cancelled right after headers
demo.pdf206 Partial Contentexactly the size of the requested chunkthe byte ranges requested afterwards

The first one is exactly the "200 with no response data" behaviour described above — the response body was still streaming when reader.cancel() closed the connection.

Back in the PDF.js source, the trigger for that cancellation is these lines in the PDFFetchStreamReader constructor:

// We need to stop reading when range is supported and streaming is
// disabled.
if (!this._isStreamingSupported && this._isRangeSupported) {
  this.cancel(new AbortException('Streaming is disabled.'));
}

When disableStream: true and the server supports Range, PDF.js abandons the full stream as soon as it has the headers, and every subsequent byte comes from PDFFetchStreamRangeReader. The whole decision looks like this:

When the full request gets cancelled

Notes: if you want a deeper look at fetch and the underlying ReadableStream, see the fourth article in this series.

The validateRangeRequestCapabilities function

Back to PDFFetchStreamReader.constructor -> fetchUrl(...).then -> validateRangeRequestCapabilities

function validateRangeRequestCapabilities({
  responseHeaders,
  isHttp,
  rangeChunkSize,
  disableRange,
}) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    assert(
      Number.isInteger(rangeChunkSize) && rangeChunkSize > 0,
      'rangeChunkSize must be an integer larger than zero.',
    );
  }
  const rv = {
    contentLength: 0,
    isRangeSupported: false,
  };
 
  const length = parseInt(responseHeaders.get('Content-Length'), 10);
  if (!Number.isInteger(length)) {
    return rv;
  }
  rv.contentLength = length;
 
  if (length <= 2 * rangeChunkSize) {
    // The file size is smaller than the size of two chunks, so it does not
    // make any sense to abort the request and retry with a range request.
    return rv;
  }
  if (disableRange || !isHttp) {
    return rv;
  }
  if (responseHeaders.get('Accept-Ranges') !== 'bytes') {
    return rv;
  }
 
  const contentEncoding = responseHeaders.get('Content-Encoding') || 'identity';
  if (contentEncoding === 'identity') {
    rv.isRangeSupported = true;
  }
  return rv;
}

This function puts the file through four gates before Range is used:

  1. The file must be larger than two chunks (length <= 2 * rangeChunkSize bails out, since cancelling and retrying would bring no benefit);
  2. Range must not be disabled (disableRange), and the resource must be http(s) (isHttp);
  3. The server must return Accept-Ranges: bytes;
  4. The response must not be transfer-compressed, i.e. Content-Encoding must be identity.

The fourth gate looks redundant at first glance — the server already returned Accept-Ranges: bytes, so why check Content-Encoding again?

const contentEncoding = responseHeaders.get('Content-Encoding') || 'identity';
if (contentEncoding === 'identity') {
  rv.isRangeSupported = true;
}

Because Range offsets are relative to the bytes as transferred, while PDF parsing needs offsets relative to the original file.

A PDF's cross-reference table (xref) stores the absolute byte offset of every object, and startxref at the end of the file points at the xref table. PDF.js issues Range requests using exactly those offsets — for example reading the last 1024 bytes to locate startxref:

fetch(url, {
  headers: { Range: `bytes=${fileSize - 1024}-${fileSize - 1}` },
});

That only works under two assumptions: fileSize equals the real PDF size, and Range: bytes=N-M returns bytes N through M of the file. As soon as the response carries Content-Encoding: gzip (or br, deflate), both assumptions break:

  1. Content-Length becomes the compressed size. validateRangeRequestCapabilities stores it as contentLength, so every later offset calculation based on it (like fileSize - 1024) is off.
  2. Range slices the compressed stream. bytes=1000-2000 returns bytes 1000–2000 of the gzip stream, not bytes 1000–2000 of the PDF. gzip is a streaming format: a middle slice has no dictionary or header, cannot be decompressed on its own, and is useless.
  3. The browser "helps" in a way that hurts. fetch decompresses the body transparently when it negotiated compression, so response.body yields original bytes while Content-Length / Content-Range still describe compressed sizes. The two sides no longer agree.

So only with Content-Encoding: identity (no transfer compression) do transferred bytes equal file bytes, Content-Length become trustworthy, and Range offsets mean anything.

As an aside: PDFs are already compressed internally with FlateDecode and friends, so adding gzip on top rarely pays off — which is why PDFs usually are not served with transfer compression in the first place, and this branch passes in the overwhelming majority of cases.

Even though PDFs are already FlateDecode-compressed internally, they still contain lots of plain strings (legacy xref tables, metadata, etc.), so compression can pay off in certain special cases. But in Range mode compression cannot be used at all (this is settled by the spec — see https://github.com/whatwg/fetch/issues/747). To handle that, StreamPDF inspects the PDF structure in the cloud to decide whether compression applies, and operates on the raw binary stream — split -> reassemble -> compress -> decompress — bringing PDFs of all shapes to the same loading speed, instead of relying on users to do special handling for a fast load.

The PDFWorkerStream class

Before going further into Range, meet a class that shows up on both paths: PDFWorkerStream. It runs on the worker thread, in src/core/worker_stream.js:

class PDFWorkerStream extends BasePDFStream {
  constructor(source) {
    super(source, PDFWorkerStreamReader, PDFWorkerStreamRangeReader);
  }
}

It is perfectly symmetrical to PDFFetchStream — same parent, same "just hand over two Reader classes" constructor. The difference is that its source has no url, only a msgHandler: it never issues network requests itself, it packages requests into messages and lets the main thread do the I/O.

That is the division of labour across the transport layer:

  • the main thread does the real I/O (PDFFetchStream) and owns the byte stream;
  • the worker does the parsing, but what it sees is still a "stream" — PDFWorkerStream;
  • the two are bridged by MessageHandler.sendWithStream, and data moves as Transferable without copying.

The PDFWorkerStreamReader class

class PDFWorkerStreamReader extends BasePDFStreamReader {
  _reader = null;
 
  constructor(stream) {
    super(stream);
    const { msgHandler } = stream._source;
 
    const readableStream = msgHandler.sendWithStream('GetReader');
    this._reader = readableStream.getReader();
 
    msgHandler.sendWithPromise('ReaderHeadersReady').then(data => {
      this._contentLength = data.contentLength;
      this._isStreamingSupported = data.isStreamingSupported;
      this._isRangeSupported = data.isRangeSupported;
 
      this._headersCapability.resolve();
    }, this._headersCapability.reject);
  }
}

Two messages, two jobs:

  • sendWithStream("GetReader") matches the main-side on("GetReader", (data, sink) => ...) and creates a pipe from the main thread's body to the worker;
  • sendWithPromise("ReaderHeadersReady") matches the main-side on("ReaderHeadersReady") and fetches metadata once, returning { isStreamingSupported, isRangeSupported, contentLength }.

The main-side onProgress callback has no counterpart here: metadata arrives in one go through sendWithPromise("ReaderHeadersReady"), and progress is reported by the readData loop via handler.send("DocProgress", ...) only when !fullReader.isStreamingSupported (i.e. streaming is disabled).

read() contains an easy-to-miss detail:

async read() {
  const { value, done } = await this._reader.read();
  if (done) {
    return { value: undefined, done: true };
  }
  // `value` is wrapped into Uint8Array, we need to
  // unwrap it to ArrayBuffer for further processing.
  return { value: value.buffer, done: false };
}

The main side sends sink.enqueue(new Uint8Array(value), 1, [value]) — wrapping the buffer in a Uint8Array so it can be transferred with zero copies; on the worker side the wrapper has to be unwrapped again via value.buffer to get back the ArrayBuffer the upper layers expect.

The PDFWorkerStreamRangeReader class

class PDFWorkerStreamRangeReader extends BasePDFStreamRangeReader {
  _reader = null;
 
  constructor(stream, begin, end) {
    super(stream, begin, end);
    const { msgHandler } = stream._source;
 
    const readableStream = msgHandler.sendWithStream('GetRangeReader', {
      begin,
      end,
    });
    this._reader = readableStream.getReader();
  }
}

begin / end go into the message untouched, and the main thread turns them into Range: bytes=begin-end-1.

cancel() is just this._reader.cancel(reason) — the message stream's onCancel cascades to the main-side sink.onCancel, which cancels PDFFetchStreamRangeReader and aborts the corresponding fetch. The whole cancellation only needs to be triggered once, at the top.

Between the main thread and the worker

After getPdfManager obtains fullReader, it does two things in parallel. The first waits for the headers and decides which pdfManager to use:

fullReader.headersReady.then(() => {
  if (!fullReader.isRangeSupported) {
    return;
  }
  pdfManagerArgs.source = pdfStream;
  pdfManagerArgs.length = fullReader.contentLength;
  // We don't need auto-fetch when streaming is enabled.
  pdfManagerArgs.disableAutoFetch ||= fullReader.isStreamingSupported;
 
  newPdfManager = new NetworkPdfManager(pdfManagerArgs);
  for (const chunk of cachedChunks) {
    newPdfManager.sendProgressiveData(chunk);
  }
  cachedChunks = null;
 
  resolve(newPdfManager);
});

The line disableAutoFetch ||= fullReader.isStreamingSupported deserves its own sentence: when streaming is enabled, auto-fetch is switched off so Range requests do not prefetch every chunk of the file, duplicating what the full stream is already downloading.

The second is a readData() loop that keeps feeding bytes from the full stream into that pdfManager:

if (newPdfManager) {
  newPdfManager.sendProgressiveData(value);
} else {
  cachedChunks.push(value);
}

So in streaming mode (the default), the full stream is not wasted once Range kicks in: it keeps draining the body into ChunkedStream, and Range requests only fill in the parts that have not arrived yet. Both sides fill the same byte array. This also explains why _progressiveDataLength is _fullReader?._loaded — the number of bytes already received is exactly what decides whether a range still needs to be requested.

With disableStream: true, on the other hand, the full reader was already cancel()ed in the previous section, so readData() gets done on its very first read() and exits; every byte then comes from Range requests.

If the headers say Range is not supported, newPdfManager is never created, the loop reads until done, and LocalPdfManager takes over:

if (!newPdfManager) {
  pdfManagerArgs.source = arrayBuffersToBytes(cachedChunks);
  cachedChunks = null;
 
  newPdfManager = new LocalPdfManager(pdfManagerArgs);
  resolve(newPdfManager);
}

There is one last safety net: cancelXHRs = reason => pdfStream.cancelAllRequests(reason), which cancels the full reader and every range reader in one shot during the window before NetworkPdfManager exists (say, the user closes the document immediately).

cancelAllRequests(reason) {
  this._fullReader?.cancel(reason);
 
  // Always create a copy of the rangeReaders.
  for (const reader of new Set(this._rangeReaders)) {
    reader.cancel(reason);
  }
}

The full Range request path

Back in the getPdfManager part of the second article, if Range is supported the document is handled by NetworkPdfManager inside the worker.

The whole Range path crosses both the worker and the main thread. Here is the overview first:

Range request path

NetworkPdfManager uses ChunkedStreamManager, and the two classes split the work like this:

  • ChunkedStream decides, chunk by chunk, whether data has been loaded, and throws MissingDataException when it has not;
  • ChunkedStreamManager turns the missing chunks into Range requests.

On the worker side, getPdfManager first creates PDFWorkerStream (already covered above), passes it as source to NetworkPdfManager, which in turn hands it to ChunkedStreamManager:

const pdfStream = new PDFWorkerStream({ msgHandler: handler }),

As soon as parsing touches a chunk that has not been loaded, MissingDataException bubbles up to NetworkPdfManager.ensure, which fires the request:

async ensure(obj, prop, args) {
  try {
    const value = obj[prop];
    if (typeof value === 'function') {
      return await value.apply(obj, args);
    }
    return value;
  } catch (ex) {
    if (!(ex instanceof MissingDataException)) {
      throw ex;
    }
    await this.requestRange(ex.begin, ex.end);
    return this.ensure(obj, prop, args);
  }
}

requestRange forwards straight to ChunkedStreamManager.requestRange, which uses groupChunks to merge the missing chunks into as few contiguous ranges as possible and then calls sendRequest(begin, end) for each. At this point begin / end are already chunk-aligned byte offsets (begin = beginChunk * chunkSize) — exactly the range that ends up in the HTTP Range header:

async sendRequest(begin, end) {
  const rangeReader = this.pdfStream.getRangeReader(begin, end);
}

So NetworkPdfManager does not request anything "by default"; it requests on demand, only when parsing actually reaches bytes that have not been downloaded. PDFWorkerStream does not override getRangeReader, so pdfStream.getRangeReader here resolves to BasePDFStream.getRangeReader:

getRangeReader(begin, end) {
  if (end <= this._progressiveDataLength) {
    return null;
  }
  const reader = new this.#PDFStreamRangeReader(this, begin, end);
  this._rangeReaders.add(reader);
  return reader;
}

which instantiates PDFWorkerStreamRangeReader, packaging the request into a message to the main thread:

constructor(stream, begin, end) {
  super(stream, begin, end);
  const { msgHandler } = stream._source;
 
  const readableStream = msgHandler.sendWithStream("GetRangeReader", {
    begin,
    end,
  });
  this._reader = readableStream.getReader();
}

The message reaches the main thread and enters on:GetRangeReader:

messageHandler.on('GetRangeReader', (data, sink) => {
  assert(this.#networkStream, 'GetRangeReader - no `BasePDFStream` instance available.');
  const rangeReader = this.#networkStream.getRangeReader(data.begin, data.end);
 
  // When streaming is enabled, it's possible that the data requested here
  // has already been fetched via the `#fullReader` implementation.
  // However, given that the PDF data is loaded asynchronously on the
  // main-thread and then sent via `postMessage` to the worker-thread,
  // it may not have been available during parsing (hence the attempt to
  // use range requests here).
  //
  // To avoid wasting time and resources here, we'll thus *not* dispatch
  // range requests if the data was already loaded but has not been sent to
  // the worker-thread yet (which will happen via the `#fullReader`).
  if (!rangeReader) {
    sink.close();
    return;
  }
 
  sink.onPull = () => {
    rangeReader
      .read()
      .then(function ({ value, done }) {
        if (done) {
          sink.close();
          return;
        }
        assert(value instanceof ArrayBuffer, 'GetRangeReader - expected an ArrayBuffer.');
        sink.enqueue(new Uint8Array(value), 1, [value]);
      })
      .catch(reason => {
        sink.error(reason);
      });
  };
 
  sink.onCancel = reason => {
    rangeReader.cancel(reason);
 
    sink.ready.catch(readyReason => {
      if (this.destroyed) {
        return; // Ignore any pending requests if the worker was terminated.
      }
      throw readyReason;
    });
  };
});

Start with the if (!rangeReader) branch.

getRangeReader returns null when end <= this._progressiveDataLength — meaning the range has in fact already been received through the full stream, the main thread just has not postMessaged it to the worker yet, so parsing on the worker side still thinks the data is missing. In that case the main thread simply calls sink.close() and does not issue a Range request.

On the worker side this corresponds to the following block in ChunkedStreamManager.sendRequest:

if (chunks.length === 0 && this.disableAutoFetch) {
  // The range request wasn't dispatched, see the "GetRangeReader" handler
  // in the `src/display/api.js` file.
  return;
}

Receiving done with zero chunks means the Range request was never dispatched, and that data will arrive via the full stream, so it returns immediately instead of spinning through a pointless retry.

Meanwhile this.#networkStream in this.#networkStream.getRangeReader(data.begin, data.end) is the PDFFetchStream instance created on the main thread. PDFFetchStream also does not override getRangeReader, so the call again ends up in the very same BasePDFStream.getRangeReader — and instantiates the class in the next section.

The PDFFetchStreamRangeReader class

class PDFFetchStreamRangeReader extends BasePDFStreamRangeReader {
  _abortController = new AbortController();
 
  _readCapability = Promise.withResolvers();
 
  _reader = null;
 
  constructor(stream, begin, end) {
    super(stream, begin, end);
    const { url, withCredentials } = stream._source;
 
    // Always create a copy of the headers.
    const headers = new Headers(stream.headers);
    headers.append('Range', `bytes=${begin}-${end - 1}`);
 
    fetchUrl(url, headers, withCredentials, this._abortController)
      .then(response => {
        const responseOrigin = getResponseOrigin(response.url);
 
        ensureResponseOrigin(responseOrigin, stream._responseOrigin);
        ensureResponseStatus(response.status, url);
        this._reader = response.body.getReader();
 
        this._readCapability.resolve();
      })
      .catch(this._readCapability.reject);
  }
 
  async read() {
    await this._readCapability.promise;
    const { value, done } = await this._reader.read();
    if (done) {
      return { value, done };
    }
    return { value: getArrayBuffer(value), done: false };
  }
 
  cancel(reason) {
    this._reader?.cancel(reason);
    this._abortController.abort();
  }
}

And here the actual Range request finally goes out. Four things in this class are worth noting.

1. end - 1 makes it a closed interval

headers.append('Range', `bytes=${begin}-${end - 1}`);

BasePDFStream.getRangeReader receives begin / end as a chunk-aligned half-open interval [begin, end), while HTTP's Range: bytes=N-M is closed, so subtracting 1 lines the boundaries up.

2. The response origin must match the full response

ensureResponseOrigin(responseOrigin, stream._responseOrigin);

stream._responseOrigin is the origin of response.url from the full request. If the server redirects some Range request to another origin, the stitched byte stream could be wrong, so this throws instead of silently concatenating.

3. _readCapability controls the timing of read()

The constructor only fires the request and does not wait for the response; read() starts by await this._readCapability.promise, so even if the caller calls read() or cancel() before the headers arrive, it will never touch a null _reader. This is the same idea as _headersCapability on the full Reader, just renamed to "ready to read".

4. Cancellation is still "close stream + abort"

cancel(reason) {
  this._reader?.cancel(reason);
  this._abortController.abort();
}

Same order as PDFFetchStreamReader.cancel: close the ReadableStream first, then abort the underlying request.

Summary

Looking at the two streams covered in this article side by side, BasePDFStream is a template, and each implementation only fills in two Reader classes:

WhereStreamFull readerRange reader
main threadPDFFetchStreamPDFFetchStreamReader (fetch)PDFFetchStreamRangeReader (Range)
workerPDFWorkerStreamPDFWorkerStreamReader (postMessage)PDFWorkerStreamRangeReader (postMessage)

A few design points worth remembering:

  1. Reader classes are injected through the constructor. getFullReader / getRangeReader / cancelAllRequests all live in BasePDFStream, and subclasses only pick which readers to use, so the layer above (ChunkedStreamManager) never needs to know whether bytes come from fetch or from a message channel.

  2. When fetch resolves shapes the whole design. Headers arrive first, so Content-Length / Accept-Ranges are available before the first byte is read, and the decision to switch to Range can be made without waiting for the download to finish.

  3. Range has four gates (validateRangeRequestCapabilities): the file must be larger than two chunks, Range must not be disabled and the resource must be http(s), the server must return Accept-Ranges: bytes, and Content-Encoding must be identity. The last one is the easiest to overlook and decides whether offsets can be trusted.

  4. Cutting the full stream is a deliberate trade-off. With disableStream: true plus Range support, the full reader is cancelled immediately — that is the 200 (canceled) in DevTools with headers but no body. Under the default configuration the full stream is never cut.

  5. Range requests are on demand. MissingDataException is thrown only when parsing actually reaches a gap, caught by NetworkPdfManager.ensure, and turned into a chunk-aligned Range request; ranges already covered by the full stream return null and are never requested twice.

  6. Cancellation cascades both ways. AbortController aborts the fetch, ReadableStream.cancel() closes the data stream, the message stream's onCancel carries the worker-side cancellation back to the main thread, and cancelAllRequests covers the window where the manager does not exist yet.

Next up we will dig into ChunkedStream and ChunkedStreamManager to see how these chunk-by-chunk responses are stitched into a contiguous byte stream that supports random access.

Related articles