Engineering Journal
Pdf Processor
Pdf Processor

Post-Mortem: The 8GB Memory Spike and the Silent Web Worker Serialization Bug

2026-08-06

TLDR

During the integration of the headless batch pipeline into the Ginexys navigation panel, two critical defects surfaced: Web Worker extractions silently returned empty HTML payloads due to a message payload key mismatch (buffer vs bytes), and focusing batch document cards triggered redundant main-thread re-extractions that pushed browser memory usage to ~8GB. We resolved both defects by unifying worker serialization keys and building a zero-re-extraction cached memory mount pipeline.
Defect AreaRoot CauseEngineering Solution
Silent Worker Extraction FailureWorker expected e.data.bytes, pool sent e.data.bufferUnify payload destructuring to check both buffer and bytes
8GB Browser Memory SpikeCard click invoked handleFile() re-extraction loopDirect memory mount from cached BatchQueueItem IR
Detached ArrayBuffer LossWorker message return omitted transferable ArrayBufferPost back bytes: bytes.buffer as transferable object

Technical problem analysis & solutions

1. The silent serialization key mismatch

When WorkerPool dispatched batch items, it passed the document payload under the property buffer:
// WORKER POOL DISPATCH
workerEntry.worker.postMessage({
    type: 'process_batch_item',
    id: task.item.id,
    buffer: arrayBuffer
}, [arrayBuffer]);

However, inside pipelineWorker.js, the message listener destructured bytes instead of buffer:

// DEFECTIVE WORKER LISTENER
self.onmessage = async (e) => {
    if (e.data.type === 'process_batch_item') {
        const { id, bytes, format } = e.data; // DEFECT: bytes was undefined!
        
        // TextDecoder received undefined, producing empty string output
        const rawText = new TextDecoder().decode(bytes); 
        self.postMessage({ type: 'batch_item_complete', id, result: { extractedHTML: rawText } });
    }
};

Because bytes was undefined, text decoding and PDF parsing silently failed and posted empty strings back to the queue.

Remediation:

Support both ArrayBuffer transfers and raw byte arrays in the worker listener:
// REFACTORED WORKER LISTENER
self.onmessage = async (e) => {
    if (e.data.type === 'process_batch_item') {
        const { id, buffer, bytes: rawBytes, format } = e.data;
        const bytes = rawBytes ? new Uint8Array(rawBytes) : (buffer ? new Uint8Array(buffer) : null);
        
        // Process valid Uint8Array
        let extractedHTML = parseDocument(bytes, format);

self.postMessage({ type: 'batch_item_complete', id, result: { extractedHTML, bytes: bytes ? bytes.buffer : null } }, bytes ? [bytes.buffer] : []); } };


2. Eliminating focus-triggered re-extraction loops

To display an extracted batch item when clicked, focusBatchItem initially called loadFileToSlot(). This re-read raw files and re-spawned geometry parsing workers on every click. Switching between 5 uploaded files resulted in 5-10 concurrent parsing pipelines, spiking browser memory to ~8GB.

Remediation:

Mount pre-extracted results from memory instantly without invoking file loading handlers:
// REFACTORED: Instant cached mount
export async function focusBatchItem(itemId, slotNum = 1) {
    const item = batchQueue.getItem(itemId);
    if (!item || item.status !== 'completed') return;

const slot = slotNum === 1 ? state.pdf1 : state.pdf2;

// Read directly from cached batch result object slot.extractedHTML = item.extractedHTML || ''; slot.bytes = item.bytes || null; slot.gxDoc = item.gxDoc || null;

// Hydrate DOM in <10ms without worker invocation applyHtmlEverywhere(slot.extractedHTML, null); if (item.format === 'pdf' && item.bytes) { await renderPDFToCanvas(item.bytes); } }

Rule of thumb: Always verify serialization key parity across Web Worker boundaries, and never invoke heavy extraction pipelines when pre-computed IR data already exists in memory.
Read this post in the full Engineering Journal →