Batch conversion patterns without a backend
How we let users convert 500 PDFs in one drop without ever leaving the browser tab.

Batch conversion is a feature every user asks for and every architect dreads. Uploading 500 PDFs is a stress test for any server. Doing it locally is a different set of problems: memory pressure, main-thread blocking, download UX.
Web Workers are non-negotiable
Any real work in a batch flow must run off the main thread. Otherwise the browser stops rendering, the fan spins up, and the user assumes the app has crashed. We spawn a dedicated worker per conversion job, up to navigator.hardwareConcurrency - 1 in parallel.
Streaming, not batching
A naïve implementation would collect all outputs in memory before letting the user download. That eats memory linearly and hits the 2 GB per-tab wall on most browsers around the 300-file mark. Instead, we stream each output into a StreamSaver-backed ZIP on disk, freeing memory as we go.
import streamSaver from "streamsaver";
import { createWriter } from "@zip.js/zip.js";
const fileStream = streamSaver.createWriteStream("converted.zip");
const writer = createWriter(fileStream);
for (const file of files) {
const result = await convertOne(file);
await writer.add(file.name.replace(/pdf$/, "docx"), result);
}
await writer.close();Recovery
If a single file in a batch fails, we log the failure to an in-memory manifest and continue. At the end, the user gets a converted.zip plus a failures.txt listing what went wrong. Nothing is lost silently.
Progress reporting
Per-file progress plus overall percent-complete keeps users oriented. On a 500-file batch, a stale UI is indistinguishable from a crash. We update the UI every 500 ms whether or not new files are done.
Bounds
We cap concurrency at 8 to keep browsers responsive on older machines. Batches above 1000 files trigger a soft warning ('this might take a while'). We have not yet found a batch size that a modern desktop cannot chew through — the practical limit is user patience, not memory.
Marek Novák
Engineering Lead at nctools
Keep reading
Engineering
OCR in the browser: how Tesseract.js and WebAssembly changed everything
A deep look at running the world's most-used open-source OCR engine at 200+ MB of trained models directly in a user's tab.
ReadEngineering
Client-side PDF extraction with pdfjs-dist: a deep dive
Mozilla's pdfjs-dist library powers every PDF reader on the web. Here is what it does well, where the sharp edges are, and how we build on top of it.
ReadEngineering
Building a zero-upload SaaS: architecture notes
How we structure a product where 90 percent of user activity never touches our server, and what that means for our monitoring, billing and support.
Read