The ArrayBuffer Detach Bug: Why Your Cached Worker Bytes Go Empty After the First PDF
TLDR
Passing typed arrays (Uint8Array) into Web Worker APIs like pdfjsLib.getDocument({ data: bytes }) triggers zero-copy ArrayBuffer transfers. Under the JavaScript specification, transferred ArrayBuffer instances are immediately neutered (byteLength === 0). If worker code caches the original Uint8Array reference for subsequent operations (such as single-page re-extraction), subsequent reads fail silently. Slicing the array (bytes.slice()) prior to transfer preserves cached byte data.
| Buffer State | Caching Approach | Post-Transfer byteLength | Re-Extraction Result |
|---|---|---|---|
| Direct Reference | _cachedBytes = bytes | 0 (Neutered / Detached) | Silent failure (Worker hangs) |
| Sliced Independent Copy | _cachedBytes = bytes.slice() | Original Byte Length | 100% Deterministic Re-Extraction |
Problem statement: the silent re-extraction hang
In our PDF processor UI, clicking "Re-extract Page" after modifying tolerance sliders produced no response from the Web Worker.
No console errors were logged, no message returned to the main thread, and the re-extraction UI button remained in a loading state indefinitely.
Technical failure mode: ArrayBuffer neutering
PDF.js transfers input ArrayBuffer objects to sub-workers using zero-copy transfers:
// DEFECTIVE WORKER CACHING: Storing reference prior to transfer
let _cachedPdfBytes = null;
self.onmessage = async (e) => { const { bytes } = e.data; _cachedPdfBytes = bytes; // Caches direct reference to Uint8Array
// PDF.js transfers the underlying ArrayBuffer, neutering _cachedPdfBytes! const pdfDoc = await pdfjsLib.getDocument({ data: bytes }).promise; };
When single-page re-extraction was triggered later:
// FAILS: _cachedPdfBytes.byteLength is now 0!
const pdfDoc = await pdfjsLib.getDocument({ data: _cachedPdfBytes }).promise;
PDF.js received an empty 0-byte buffer. The internal rejection was caught inside a generic try/catch block that lacked routing for re-processing errors, causing a silent hang.
The fix & architecture: pre-transfer buffer copying
Slice bytes to create an independent ArrayBuffer copy prior to invoking pdfjsLib.getDocument():
// REFACTORED WORKER: Slice before transfer
let _cachedPdfBytes = null;
self.onmessage = async (e) => { const { bytes } = e.data;
// 1. Create an independent copy BEFORE transfer neuters the buffer _cachedPdfBytes = bytes.slice();
// 2. Pass original bytes to PDF.js const pdfDoc = await pdfjsLib.getDocument({ data: bytes }).promise; };
Defense-in-Depth guard verification
Add length verification and explicit error handling prior to reprocessing:async function handleReprocessPageRequest(pageNum) {
if (!_cachedPdfBytes || _cachedPdfBytes.byteLength === 0) {
self.postMessage({
type: 'error',
isReprocess: true,
error: 'Cached PDF bytes are missing or neutered. Re-run initial document extraction.'
});
return;
}
// Safe to execute re-extraction using valid cached bytes const pdfDoc = await pdfjsLib.getDocument({ data: _cachedPdfBytes }).promise; const page = await pdfDoc.getPage(pageNum); // ... execute single-page pipeline ... }
Rule of thumb: Always execute.slice()onUint8Arraydata before passing buffers to Web Worker APIs that use zero-copyTransferableobjects.