PDF.js Deep Dive 5: PDFFetchStream
October 25, 2026 · StreamPDF Team

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:
- Why does PDF.js sometimes cancel a
200request right after issuing it? - Who triggers a Range request, and why does it have to cross both the worker and the main thread?
- Why is Range only possible when
Content-Encodingisidentity?
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:
- PDF.js can read
Content-Length,Accept-Rangesand other key metadata while the download has barely started, without waiting for the whole file; 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;- If the server supports byte ranges, PDF.js can cut the full stream and switch to
Rangeheaders, 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):
| Mode | disableStream | Full stream | What Range is for |
|---|---|---|---|
| Streaming (default) | false | Downloads to the end, feeding ChunkedStream | Fills gaps the parser outran (e.g. xref at the tail) |
| Streaming disabled | true | cancel()ed right after the headers | Carries 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. Thefetchpromise 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 thefetchpromise has not resolved yet (headers have not arrived), it rejects withAbortError.
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:
| Request | Status | Size | What it is |
|---|---|---|---|
demo.pdf | 200 + (canceled) | stops after a few dozen KB | full request, cancelled right after headers |
demo.pdf | 206 Partial Content | exactly the size of the requested chunk | the 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:
Notes: if you want a deeper look at
fetchand the underlyingReadableStream, 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:
- The file must be larger than two chunks (
length <= 2 * rangeChunkSizebails out, since cancelling and retrying would bring no benefit); - Range must not be disabled (
disableRange), and the resource must be http(s) (isHttp); - The server must return
Accept-Ranges: bytes; - The response must not be transfer-compressed, i.e.
Content-Encodingmust beidentity.
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:
Content-Lengthbecomes the compressed size.validateRangeRequestCapabilitiesstores it ascontentLength, so every later offset calculation based on it (likefileSize - 1024) is off.- Range slices the compressed stream.
bytes=1000-2000returns 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. - The browser "helps" in a way that hurts.
fetchdecompresses the body transparently when it negotiated compression, soresponse.bodyyields original bytes whileContent-Length/Content-Rangestill 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 asTransferablewithout 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-sideon("GetReader", (data, sink) => ...)and creates a pipe from the main thread's body to the worker;sendWithPromise("ReaderHeadersReady")matches the main-sideon("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:
NetworkPdfManager uses ChunkedStreamManager, and the two classes split the work like this:
ChunkedStreamdecides, chunk by chunk, whether data has been loaded, and throwsMissingDataExceptionwhen it has not;ChunkedStreamManagerturns 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:
| Where | Stream | Full reader | Range reader |
|---|---|---|---|
| main thread | PDFFetchStream | PDFFetchStreamReader (fetch) | PDFFetchStreamRangeReader (Range) |
| worker | PDFWorkerStream | PDFWorkerStreamReader (postMessage) | PDFWorkerStreamRangeReader (postMessage) |
A few design points worth remembering:
-
Reader classes are injected through the constructor.
getFullReader/getRangeReader/cancelAllRequestsall live inBasePDFStream, and subclasses only pick which readers to use, so the layer above (ChunkedStreamManager) never needs to know whether bytes come fromfetchor from a message channel. -
When
fetchresolves shapes the whole design. Headers arrive first, soContent-Length/Accept-Rangesare available before the first byte is read, and the decision to switch to Range can be made without waiting for the download to finish. -
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 returnAccept-Ranges: bytes, andContent-Encodingmust beidentity. The last one is the easiest to overlook and decides whether offsets can be trusted. -
Cutting the full stream is a deliberate trade-off. With
disableStream: trueplus Range support, the full reader is cancelled immediately — that is the200 (canceled)in DevTools with headers but no body. Under the default configuration the full stream is never cut. -
Range requests are on demand.
MissingDataExceptionis thrown only when parsing actually reaches a gap, caught byNetworkPdfManager.ensure, and turned into a chunk-alignedRangerequest; ranges already covered by the full stream returnnulland are never requested twice. -
Cancellation cascades both ways.
AbortControlleraborts the fetch,ReadableStream.cancel()closes the data stream, the message stream'sonCancelcarries the worker-side cancellation back to the main thread, andcancelAllRequestscovers 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.