I built this because I had to compress a signed contract, and every "free online PDF compressor" I found wanted me to upload it first.
So I set myself one rule for AnyFileKit: the file never leaves the user's computer. No upload endpoint, no temporary storage, no "we delete it after an hour." Everything happens in the tab.
The stack is boring on purpose. Static Astro pages on Cloudflare Workers, pdf-lib and pdf.js for PDFs, ffmpeg.wasm for video, tesseract.js for OCR, SheetJS for Excel. It now runs 23 tools.
The boring stack was the easy part. Here's what actually broke.
The ffmpeg core was too big to deploy
The single-threaded ffmpeg-core.wasm is 30.7 MB. Cloudflare Workers static assets have a 25 MiB limit per file, so the first deploy was simply rejected.
The obvious fix is loading ffmpeg from a public CDN. I didn't want that. "Your file stays on your machine" is much easier to believe when the page makes no third-party requests at all, and people do open the Network tab to check.
So the wasm ships gzipped (about 9.8 MB) and the page decompresses it itself:
const gz = await fetch('/ffmpeg/ffmpeg-core.wasm.gz');
const wasm = await new Response(
gz.body.pipeThrough(new DecompressionStream('gzip')),
).blob();
const wasmURL = URL.createObjectURL(wasm);
await ffmpeg.load({
classWorkerURL: '/ffmpeg/worker.js',
coreURL: '/ffmpeg/ffmpeg-core.js',
wasmURL,
});
URL.revokeObjectURL(wasmURL);
DecompressionStream is in every current browser, so this needs no library. The download only starts when someone clicks "start" on a video tool. Nobody pays 10 MB just to look at the page.
It's still the worst part of the experience. The first video job waits for that download.
I picked the single-threaded ffmpeg on purpose
The multi-threaded ffmpeg build is faster. It also needs SharedArrayBuffer, and that means sending COOP and COEP headers for the whole site. Cross-origin isolation blocks third-party scripts and iframes that don't opt in, and I wanted to keep that door open.
So it's single-threaded. Slower, and I'm fine with that.
One thing I expected to be a problem wasn't. I ran the same 1080p, 60-second compression twice on an M-series Mac in Chrome: once with the tab visible (114.9 s) and once with it hidden the whole time (112.9 s). ffmpeg runs in a worker, and Chrome doesn't throttle it when you switch tabs.
pdf.js was a different story.
pdf.js freezes the moment you switch tabs
Compressing a PDF means rendering each page to a canvas. The first version worked fine as long as you watched it. Switch to another tab mid-job, come back, and it was sitting on the same page it had been on when you left.
The cause is one option. With the default intent: 'display', pdf.js moves through a page in small pieces scheduled with requestAnimationFrame. Chrome pauses rAF in background tabs. So the render just stops, forever, with no error.
await page.render({
canvasContext: ctx,
viewport,
intent: 'print',
}).promise;
'print' schedules its work with setTimeout, which keeps running in the background. And turning a page into pixels is closer to printing than displaying anyway. That one word appears in three places in my code now, each with a comment so I don't "fix" it later.
Big files hit limits I didn't know existed
There's no upload, so there's no server-side size cap. The limits are the user's browser and memory, and they show up in strange places.
Chrome won't read more than 2046 MiB of a file into a single ArrayBuffer. ffmpeg.wasm also keeps its input and output in an in-memory file system inside a 32-bit wasm address space. In my tests, a 1.67 GB output worked (heap peaked around 3.34 GB), and a 2.3 GB one threw RangeError: Array buffer allocation failed. Because it's a normal exception, I catch it and tell the user to close some tabs or cut the file, instead of letting the page crash.
For cutting video, I stopped using ffmpeg entirely. A quick cut or a "keep the original audio" export doesn't need decoding at all. The code reads just the moov box (the MP4 index) with mp4box.js, working out which byte ranges hold the samples it needs. It builds the new file as a Blob stitched together from file.slice() pieces. The whole video is never loaded into memory at once, so neither limit applies.
It starts small, too. Most MP4s keep moov near the front, so it reads 1 MB, then 4, 16 and 64 before deciding. If it has read 256 MB and still found no index, the file probably isn't a clean MP4, and it goes back to ffmpeg.
PDF to Word is educated guessing
This one isn't a bug. It's a limitation I had to make peace with.
A PDF has no paragraphs, no headings and no reading order. It has glyphs drawn at coordinates. Every "PDF to Word" converter, mine included, is rebuilding structure from positions: which lines sit close enough to be a paragraph, where the columns are, whether that bigger bold line is a heading.
Some things I just drop. Rotated and vertical text (book spines, watermarks, chart axis labels) has no sensible place in a flowing Word document, and forcing it in scrambles the body text. So the converter skips it and counts how much it skipped.
I'd rather say that plainly than pretend the conversion is exact.
The one part that isn't local
There are three AI translators on the site (PDF, image, video), and the models are far too big to run in a tab. So those do send data out. Still not the file, though. The browser pulls the text out of the PDF or image, or the audio track out of the video, and sends only that. The page says so right above the upload box.
That's also the only paid part. Model calls cost real money. The 23 local tools cost me nothing to run, since your computer does the work.
If you want to poke at it
- Compress video (ffmpeg.wasm, single-threaded)
- PDF to Word (the guessing described above)
- Merge PDF (pdf-lib)
- Image to text (tesseract.js)
Open DevTools on the Network tab while you use them. Beyond the page's own scripts and the wasm files, you shouldn't see anything go out.
Disclosure: I built AnyFileKit, and it's a one-person project. I'm curious what other people have run into doing heavy processing client-side. Have you found a better way around the 2 GB read limit than slicing?
Top comments (0)