Disclosure: I build Tool Pantry with AI coding agents, and this article was drafted with AI assistance from my own engineering notes and test logs. Every technical claim below — file sizes, byte counts, pixel diffs, MD5s — was verified against the source code or real test output, and I've marked the two places where I had to correct an earlier conclusion.
Every "free online PDF tool" works the same way: you pick a file, it uploads to their server, they process it, you download the result. That's fine until you're handling a passport scan, a signed contract, or a medical record.
We built Tool Pantry on the opposite premise: 208 tools, zero uploads. PDF, images, video, audio, text. Nothing ever leaves the device — not by policy, by architecture.
That constraint turned out to be a much better engineering problem than we expected. Here are the seven things that actually bit us.
1. The 25 MiB wall — why we abandoned ffmpeg.wasm
Video compression was the first "obviously we'll use the obvious library" decision. ffmpeg.wasm is the standard answer for browser video.
It died at deploy time.
ffmpeg-core.wasm is 30.7 MiB. Cloudflare Workers caps a single asset at 25 MiB. Not a soft limit — the deploy just fails. We could have moved off Workers to keep the library, but shipping a 30 MB download to every visitor is its own problem.
So we rebuilt the pipeline on native browser primitives:
| Stage | Library | Size |
|---|---|---|
| Demux | MP4Box.js | ~200 KB |
| Decode / scale / encode |
WebCodecs (VideoDecoder, VideoEncoder) |
built in |
| Mux | mp4-muxer | ~50 KB |
Total: ~390 KB instead of ~30 MB — a 77× reduction. And it's not just smaller, it's faster, because WebCodecs hits hardware encoders instead of compiling x264 to WASM.
Verified output: 1280×720 6s source → 854×480 H.264 Baseline + AAC, 1.81 MB, confirmed with ffprobe picking apart the actual container.
Lesson: the ecosystem's obvious library was written when the platform couldn't do the job. Check whether the platform caught up before you ship the workaround.
2. pdf-lib's removePage doesn't delete anything
Deleting a page from a PDF should make the file smaller. Ours got bigger:
793 KB → remove one page → 812 KB
removePage() unlinks the page from the page tree, but the page's content stream, images, and fonts stay in the file — reachable through the incremental-update structure. The bytes are still there. Forensically, the "deleted" page is still in your document.
The fix is a rebuild: create a fresh PDFDocument, copyPages() only the pages you're keeping, and save that.
812 KB → copyPages rebuild → 510 KB
A 37% reduction — the data genuinely gone.
The side effect is worth disclosing to users: rebuilding a PDF drops its metadata (author, producer, creation dates). We say so on the page rather than letting people discover it.
Lesson: in PDF, "delete" almost always means "unlink." If a user's threat model includes recovering deleted content, unlinking is not enough.
3. constructor.name is a lie in minified builds
We shipped a PDF form-filler that detects AcroForm fields — text, checkbox, dropdown, radio — and renders the right input for each.
It worked perfectly in development and detected nothing in production.
Root cause: pdf-lib ships minified. In the minified build, every class's constructor.name is "n". Our type dispatch (field.constructor.name === 'PDFTextField') matched nothing.
The fix is instanceof against the exported classes, which survives minification because it compares actual prototype chains:
var isText = field instanceof PDFLib.PDFTextField;
var isCheck = field instanceof PDFLib.PDFCheckBox;
var isDropdown = field instanceof PDFLib.PDFDropdown;
var isOptionList = field instanceof PDFLib.PDFOptionList;
var isRadio = field instanceof PDFLib.PDFRadioGroup;
Lesson: never dispatch on constructor.name for anything that ships minified — including your own code, if a bundler might rename it. instanceof or an explicit discriminant field, always.
4. MP4 rotation lives in the header, not the pixels
A phone video shot in portrait is usually stored landscape, with a rotation matrix in the tkhd box telling the player "turn this 90°." The pixels never change; the metadata does.
MP4Box.js exposes both, and they disagree:
-
track.track_width/track_height→ the display size fromtkhd -
videoTrack.video.width/height→ the encoded size fromstsd
We used the first one everywhere. Every operation on a rotated video was wrong — crops landed in the wrong place, blends misaligned, output dimensions swapped.
Once we understood the split, a better option appeared for the operations that don't need pixel access. Rotating a video isn't decoding 30 million pixels and re-encoding them — it's rewriting one 3×3 matrix in a header.
We rewrite the tkhd matrix directly. Verified: byte-identical result to re-encoding with transpose=1, pixel difference 0.0. It finishes in 0.0 seconds on a 6-second clip, because nothing is decoded.
Same trick for muting: stream-copy the video track, drop the audio track. The output video bitstream's MD5 is identical to the source. Lossless, verifiable, instant.
Lesson: before you write the pixel loop, check whether the answer is already a number in a header.
5. The color bug that wasn't — verify your measurement tool first
This one nearly cost us a working pipeline.
We compared input and output video frames pixel by pixel and got a clean, damning result:
output ≈ 0.81 × source + 23
That formula is the signature of a YUV↔RGB range mismatch — the classic full-range (0–255) vs. limited-range (16–235) bug. Textbook. So we started hunting for it: checked the VUI and colr atoms, inspected our VideoFrame color-space options, ran encode round-trips, scanned for time-alignment offsets, generated difference maps.
We found nothing, because there was nothing to find.
ffprobe was reading our untagged source file and guessing the YUV matrix. It guessed wrong. The "0.81× + 23" was an artifact of the measuring tool, not a defect in our code.
The real test: decode source and output in the same decoder (Chrome) and compare flat regions — delta ≤ 3, which is just normal lossy-compression noise.
We were one commit away from "fixing" a correct color pipeline and breaking it.
Lesson: when a measurement gives you a textbook-perfect diagnosis, be suspicious. Confirm the tool before you trust the verdict.
6. SHA256 is not SHA-256
await crypto.subtle.digest('SHA256', data);
// → NotSupportedError
WebCrypto wants the hyphenated form: 'SHA-256'. The un-hyphenated spelling fails with an error that gives you no hint about the format being the problem.
Our hash tool now maps friendly names to the exact WebCrypto identifiers:
const webcryptoNames = {
sha1: 'SHA-1', sha256: 'SHA-256', sha384: 'SHA-384', sha512: 'SHA-512'
};
We checked the output byte-for-byte against Python's hashlib to be sure. It matches.
7. Transparent canvas pixels have garbage color channels
The signature tool has a mode that turns a photo of a signature on paper into a clean transparent PNG: threshold away the paper, keep the ink, recolor, auto-crop.
Bug: it kept hallucinating ink in empty areas.
Cause: in a canvas, a fully transparent pixel can still have color values — and drawing operations often leave them at 0,0,0 (black). Our threshold ("dark pixels = ink") read the entire transparent region as ink.
Fix: gate on alpha before thresholding.
if (alpha === 0) continue; // never trust the color of an invisible pixel
Lesson: transparent pixels are not "empty." Their color channels are whatever the last write left behind. Always check alpha first.
What this actually costs you (the honest section)
Client-side isn't free. The real trade-offs:
- Download size. Background removal needs a U²-Net model via ONNX Runtime Web — about 15 MB, lazy-loaded. OCR needs Tesseract WASM — about 8 MB. First use of those two tools is a real wait.
-
Browser floors. The video tools need
WebCodecs— Chrome/Edge, and Safari 17+. Older browsers can have the PDF and image tools, not the video ones. -
Threading requires headers. ONNX Runtime's multithreaded path needs
COOP: same-origin+COEP: require-corp. That's fine for a site with no third-party embeds, and it means those two headers are now load-bearing. - And there's a ceiling. Anything that genuinely needs a large model or a server-side API — LLM summarization, translation, image generation — we don't build. We say so instead of shipping a fake version.
But the floor is much higher than it was two years ago, and WebCodecs is the reason. Video processing in the browser went from "compile ffmpeg to WASM" to "call the platform."
The stack
Everything is permissively licensed and self-hosted — no CDN dependencies, no third-party calls:
- Video/audio: WebCodecs · MP4Box.js · mp4-muxer · lamejs
- PDF: pdf-lib · pdf.js
- Images: native canvas · heic2any
- ML: ONNX Runtime Web (U²-Net) · Tesseract.js
The whole thing is 208 tools with no build step, no backend, and no accounts. If you want to poke at the implementation, it's at toolpantry.app — and the raw measurements behind the numbers above are written up with methodology at compression-benchmarks and local-processing-benchmarks.
Which of these have you hit? The constructor.name one feels like it should be common knowledge and somehow isn't. And I'd genuinely like to know if anyone has a case where ffmpeg.wasm was still the right call — I couldn't find one that fit inside a 25 MiB limit, but that's a deployment constraint, not a general truth.

Top comments (0)