Processing 100MB PDFs in the Browser: The Performance Optimizations That Made TinyPDF Usable
When I built TinyPDF (https://tinypdf.cn/?utm_source=devto&utm_medium=blog&utm_campaign=performance_optimization&utm_content=devto_performance_2026-07-28), I had one hard rule: no backend.
Everything had to run in the browser. No file uploads, no servers, no costs. Just drag, drop, compress, download.
But when I tested the first version with a real portfolio—88MB, 45 pages, full of high-res images—it froze the tab for 12 seconds.
Here's what I changed to get that down to 2 seconds, without losing any features.
1. Use Web Workers for PDF Parsing (Don't Block the Main Thread)
The first mistake: I ran PDF.js parsing directly on the main thread.
// ❌ Bad: Blocks UI while parsing
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
The fix: Offload everything to a Web Worker. The main thread only handles user input and progress updates.
// ✅ Good: Web Worker does the heavy lifting
// Main thread
const worker = new Worker('pdf-compressor.worker.js');
worker.postMessage({ type: 'process', data: arrayBuffer, targetSizeMB: 2 });
worker.onmessage = (e) => {
if (e.data.type === 'progress') updateProgress(e.data.percent);
if (e.data.type === 'done') downloadBlob(e.data.blob);
};
Result: Tab stays responsive even with 100MB files.
2. Stream Image Processing (Don't Load All Pages Into Memory)
Second mistake: I loaded every page into memory at once before processing.
For a 45-page portfolio, that's 45 full-res images in memory simultaneously.
The fix: Process one page at a time, and stream results to the output blob incrementally.
// ✅ Good: Process one page, free memory, repeat
for (let i = 1; i <= numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 1 });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
// Render page to canvas
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
// Compress and add to output
const compressedImage = compressCanvas(canvas, currentQuality);
addToOutputBlob(compressedImage);
// Clean up immediately (critical for 100MB files)
page.cleanup();
canvas.width = 0;
canvas.height = 0;
updateProgress(i / numPages * 100);
}
Result: Memory usage stays flat even for 100-page documents.
3. Binary Search for Target Size (Avoid Wasted Cycles)
The core feature of TinyPDF is "compress to an exact target size in MB."
My first approach: Start at 100% quality, keep dropping by 10% until we hit the target.
That could mean 5-10 full passes over the document.
The fix: Binary search on quality.
// ✅ Good: Binary search finds optimal quality in ~5 steps
let minQuality = 0.1;
let maxQuality = 1.0;
let bestQuality = 1.0;
let bestSize = Infinity;
for (let attempt = 0; attempt < 5; attempt++) {
const midQuality = (minQuality + maxQuality) / 2;
const size = await estimateSizeAtQuality(midQuality);
if (size <= targetSizeBytes) {
// We're under target—see if we can go higher quality
bestQuality = midQuality;
bestSize = size;
minQuality = midQuality;
} else {
// Over target—need to go lower
maxQuality = midQuality;
}
}
Result: Gets to optimal quality in 5 attempts, not 10+.
4. Early Termination for Impossible Targets
If a user asks to compress a 50MB document of pure text to 1MB, that's impossible—there's no image data to drop.
My first version would try anyway, wasting 30 seconds before failing.
The fix: Calculate a theoretical minimum size first.
// ✅ Good: Estimate minimum possible size before trying
const textVectorSize = await estimateTextVectorSize(pdf);
if (targetSizeBytes < textVectorSize * 0.9) {
showWarning(`That target size might be too small—this document has ${(textVectorSize/1024/1024).toFixed(1)}MB of non-image content.`);
}
Result: User gets immediate feedback, no wasted processing.
The Result
- 100MB file: 12s → 2s
- Tab stays responsive the whole time
- Memory doesn't balloon
- No backend, no uploads, no account needed
What I Learned
Performance optimization isn't about "making it fast." It's about:
- Not blocking the main thread (users notice freezes more than slowness)
- Cleaning up immediately (memory is finite, especially on mobile)
- Avoiding unnecessary work (binary search > linear search)
- Setting expectations early (tell users when a target might be impossible)
What's the biggest performance win you've had in a browser-only app?
Top comments (0)