Last time I wrote about compressing images in the browser with the Canvas API. I ended that post by saying the PDF version of the story was messier. It is, and one of the bugs sat in production longer than I'd like to admit.
Same constraint as before: everything runs on the user's device. No upload, no server, no API.
A PDF has no quality slider
With an image you call canvas.toBlob(resolve, 'image/jpeg', 0.7) and you're done. There's a dial, you turn it.
A PDF is a container. Fonts, vector paths, embedded images, metadata, and a cross-reference table holding it together. No single number makes it smaller. What's making a given file heavy depends entirely on what's inside it, and two documents with the same page count can need completely different treatment.
You get roughly two options.
Rebuild the file structure. Load it with pdf-lib and write it back out:
js
import { PDFDocument } from 'pdf-lib';
const pdf = await PDFDocument.load(bytes);
const out = await pdf.save({ useObjectStreams: true });
This is lossless. Text stays selectable, fonts stay fonts. The savings are modest and sometimes zero, because all you're doing is tidying up structure.
Re-render every page. Draw each page onto a canvas with pdf.js, re-encode it as JPEG, then assemble a new PDF from those images. This is where the impressive percentages come from, and it costs you a lot: text becomes pixels, selection stops working, screen readers get nothing, and small type goes fuzzy at low quality.
Most of the online compressors advertising "up to 90% smaller" are quietly doing the second one.
The bug: pdf.js empties your buffer
This is the one that cost me real debugging time.
Here's the shape of the code that broke:
js
const buf = await file.arrayBuffer();
const doc = await pdfjsLib.getDocument({ data: buf }).promise;
const compressed = await compress(doc);
if (compressed.size >= file.size) {
// Can't beat the original, so hand it back untouched
return new Blob([buf], { type: 'application/pdf' });
}
return compressed;
Looks fine. It isn't.
getDocument({ data }) transfers the ArrayBuffer to its worker. Transferred, not copied. The moment that happens your buf is detached and its byteLength is 0. No error, no warning, just an empty buffer sitting where your file used to be.
So the fallback branch, the one that runs when a PDF is already well optimized and can't be shrunk, handed people a 0-byte download. And it only ever fired for files that couldn't be compressed, which is precisely the case I never thought to test.
The fix is one line:
js
if (compressed.size >= file.size) {
return file; // File already extends Blob
}
You never needed to reconstruct anything.
If you genuinely need the bytes after loading, copy before you hand them over:
js
const buf = await file.arrayBuffer();
const doc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise;
Two things worth knowing. getDocument also takes a plain Uint8Array, and the same detach rule applies there. And this isn't a pdf.js bug. Transferables are how you move data into a worker without copying it, and losing access on the sending side is the entire point. It's documented behaviour. I just didn't read closely enough.
Never hand back a bigger file
Same rule as the image post, and it matters more here:
js
if (out.byteLength >= file.size) return file;
PDFs produced by decent tooling are frequently well compressed already, and re-saving one can add bytes rather than remove them. A compressor that returns a larger file while reporting success is worse than one that does nothing at all.
Memory will end you
Rendering every page to canvas at full resolution kills a mobile tab. A 40-page document at 2x scale is an enormous number of pixels to hold at once, and the tab usually dies silently.
Render one page, encode it, throw the canvas away, then move to the next. Don't collect canvases in an array and process them at the end. I also cap the render scale instead of letting page dimensions decide it for me.
What I'd tell someone starting this
Try the lossless path first and measure whether it was enough. Only rasterize when the user asks for it and understands what they're trading away.
And test the path where compression fails. That's where my bug lived, and it's the branch nobody writes a test for.
The tool is at SwiftTooly if you want to try it. Free, and the file never leaves your machine.
Top comments (0)