--- title: "Building a PDF Editor That Never Uploads Your Files" date: 2026-06-23 category: "Case study" --- # Building a PDF Editor That Never Uploads Your Files How FreePDF.lol merges, compresses, and redacts PDFs inside the browser: what stays on your device, why rendering differs from editing, and where memory runs out. *By Pranav Gawande. Published 2026-06-23, updated 2026-11-05. https://pranv.xyz/blog/privacy-first-pdf-editor* A PDF merger is one of the smallest tools on the internet. You pick two files, press a button, and get one file back. On most websites, though, that button quietly does something much bigger: it uploads your documents to someone else's computer. For a concert ticket, that's fine. For a signed contract, a bank statement, or a passport scan, it's a strange trade. You needed two files joined together, and in exchange you handed copies of both to a server you can't inspect, to be kept for however long its operator decides. I built [FreePDF.lol](https://freepdf.lol) around a different rule: the document never leaves the browser tab. Merging, splitting, compressing, signing, redacting, and the other tools all run on the device that already has the file. The project is [open source](https://github.com/pranavv00/freepdf.lol), so everything in this article can be checked against the real implementation, and the code below comes straight from it. This is a walk through how that works: what "in the browser" actually means, why showing a PDF and editing one are different jobs, why memory becomes the main engineering problem, and what still crosses the network. ## What browser-based PDF processing means Browser-based PDF processing means the code that edits the document runs on your own device, inside the web page. The server's only job is to deliver that code. When you choose a file, the page reads it from your disk into memory, transforms it with JavaScript and WebAssembly, and hands you the result as a download. The document's bytes never travel to a server. The difference is easiest to see side by side. > **Figure 1.** Where the document goes. An upload-based tool makes a copy on someone else's server; an in-browser tool only downloads code. > > Two columns compare the paths. Upload-based tool: you choose a file on your device, the file is uploaded to a server, the server processes it, you download the result, and the server's copy is deleted at some point you cannot verify. In-browser tool: the page downloads its code from the server, you choose a file on your device, the browser tab processes it with JavaScript and WebAssembly, and you save the result. No document bytes are sent to a server. In the upload model, your file makes a round trip, and the most sensitive part of that trip is the part you can't see: what happens to the server's copy afterwards. Many services promise to delete files after an hour or a day. That may well be true. You just have no way to check. In the browser model, there is no copy to delete because no copy was made. The cost is that your device now does all the work, and that's where most of the interesting engineering lives. The wording of the promise matters. A website still has to download its own code, so "nothing goes over the network" would be false. The accurate version is narrower: the network brings code to you, and it never carries your document away. That claim is also easy to test. Open your browser's developer tools, switch to the Network tab, and merge two files. You'll see the page load scripts, and you won't find a single request whose body contains your PDF. ## Following one merge from file picker to download Here's what happens when you merge three PDFs on FreePDF. > **Figure 2.** One merge on FreePDF, step by step, with what each step holds in memory. > > Six steps. 1: Choosing files gives the page File objects, which are references; nothing is read yet. 2: The page hands the files to a Web Worker so the interface stays responsive. 3: The worker reads the first kilobyte of each file and checks for the %PDF- header. 4: It reads each file with arrayBuffer(), parses it with pdf-lib, and copies its pages into a new document. 5: It serializes the new document with save() and transfers the bytes back to the page without copying them, then the worker is terminated. 6: The page stores the result in IndexedDB and downloads it through a temporary blob URL that is revoked after ten seconds. When you pick files, the browser gives the page `File` objects. A `File` is a reference to data on your disk, not the data itself, so selecting a 200 MB scan costs almost nothing. The [File API](https://developer.mozilla.org/en-US/docs/Web/API/File_API) only reads bytes when code asks for them. The page passes those references to a [Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers), a background thread with its own event loop. Everything heavy happens there, so a large merge doesn't freeze scrolling or buttons. The first thing the worker reads from each file is a tiny slice. Every PDF begins with a `%PDF-` header, so the first kilobyte is enough to reject a renamed JPEG before anything large is loaded. Here's the check from [`document.ts`](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/pdf/document.ts): ```ts export async function readPdfBytes(file: Blob): Promise { if (!file.size) throw new Error("This file is empty. Choose a PDF with content."); const header = await file.slice(0, 1024).text(); if (!header.includes("%PDF-")) throw new Error("This file is not a PDF. Choose a valid PDF document."); return new Uint8Array(await file.arrayBuffer()); } ``` Only after that check does the whole file come into memory, through `arrayBuffer()`. The merge itself uses [pdf-lib](https://pdf-lib.js.org/). Here is FreePDF's [`merge.ts`](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/pdf/merge.ts), simplified by removing its progress reporting: ```ts const { PDFDocument } = await import("pdf-lib"); const mergedPdf = await PDFDocument.create(); for (const file of files) { const pdf = await loadPdf(file); const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices()); pages.forEach((page) => mergedPdf.addPage(page)); } return mergedPdf.save(); // a Uint8Array containing the new PDF ``` When the worker finishes, it sends the result back to the page: ```ts self.postMessage({ type: "done", bytes }, { transfer: [bytes.buffer as ArrayBuffer] }); ``` That `transfer` option is doing real work. By default, `postMessage` copies its data, so a 150 MB result would briefly exist twice. A [transfer](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) moves ownership of the memory to the page instead of copying it. The worker's buffer becomes empty and unusable, which doesn't matter, because FreePDF terminates the worker as soon as the result arrives. Finally, the page saves the merged file to the browser's own storage and shows it ready to download. Pressing Download wraps the bytes in a temporary `blob:` URL attached to a link with a `download` attribute. FreePDF revokes that URL ten seconds later so the browser can release it. ![Screenshot of FreePDF's merge workspace listing invoice-sample.pdf, terms-sample.pdf and appendix-sample.pdf, followed by a panel reading "All done. Your file is ready. Processed right here on your device.", a merged.pdf result of 76 KB, a Download PDF button, and a note saying the file is available in Recent files on this device.](https://pranv.xyz/blogs/privacy-first-pdf-editor/freepdf-merge-result.webp) > **Figure 3.** FreePDF after merging three sample PDFs. The result is marked as processed on the device and kept under Recent files, which live in the browser's IndexedDB storage. The "Recent files" note in that screenshot is a design decision worth pausing on, because local doesn't automatically mean temporary. FreePDF keeps recent results in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API), the browser's built-in database, so you can download them again or send them to another tool without choosing the file again. Those files never leave your device, but they do stay on it. FreePDF limits that with a session rule in [`storage.ts`](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/storage.ts): if more than an hour has passed since you last used the site, it clears all stored files before reading or writing anything new. The Recent files panel lets you delete them sooner. A privacy-first tool still has to decide how long local data should live, and say so. ## Showing a PDF and editing a PDF are different jobs It's natural to think of a PDF as a picture of a page. It isn't. A PDF is a structured file. Each page has a content stream, a list of drawing instructions such as "use this font and place these characters here", and the file also holds fonts, images, links, form fields, annotations, and metadata. That structure is why you can select text in a PDF, and why a 40-page text document can be smaller than a single photo. So there are two very different ways to work with one. **Rendering** turns those instructions into pixels. FreePDF uses [PDF.js](https://mozilla.github.io/pdf.js/), Mozilla's PDF renderer and the engine behind Firefox's built-in PDF viewer, for page thumbnails and previews. Rendering is how you *see* a document. **Modifying** works on the structure itself. pdf-lib copies pages between documents, rotates them, removes them, and fills in form fields, and the text stays text. Merging this way keeps what matters about each page: selectable text, sharp vector graphics, and a small file size. There's a third option that looks tempting: render each page to an image and wrap the images in a new PDF. The output looks identical, but the text is gone. Search stops working, screen readers have nothing to read, and the file usually gets bigger. Which approach you choose isn't an implementation detail. It decides what the user gets back. FreePDF's [compressor](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/pdf/compress.ts) shows the difference. By default it only performs a lossless rewrite, which repacks the document's internal objects more compactly, and then keeps whichever version is smaller: ```ts const lean = await pdf.save({ useObjectStreams: true }); const original = new Uint8Array(buffer); const lossless = lean.length < original.length ? lean : original; if (!allowRasterize) return lossless; ``` Turning pages into JPEG images can shrink a scanned document dramatically, but it destroys the text, so it only happens when you tick a checkbox that explains the trade. Even then, the code keeps the lossless version if the image version turns out larger. ### When destroying the structure is the point Redaction is the opposite case, and it's where the difference between rendering and editing matters most. If you draw a black rectangle over an account number with a PDF editing library, the rectangle is just a new drawing instruction placed on top. The original characters are still in the page's content stream underneath. Anyone can select them, copy them, or pull them out with a text extraction tool. Published legal and government documents have leaked information in exactly this way. > **Figure 4.** Two redactions that look identical. Only the second one removes the text. > > Left: a page with a black box drawn over an account number. The box is an extra drawing on top, so a text extraction tool still returns "Account 0000 1234 5678". Right: the same page rebuilt from pixels, with the black area painted into an image. A text extraction tool returns nothing, because no text from the original page structure was copied. FreePDF's redaction rebuilds every page from pixels instead. It renders the page to a canvas with PDF.js, paints solid black over each selected area, and places the resulting image into a brand-new PDF. A comment in [`redact.ts`](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/pdf/redact.ts) states the guarantee: no source text, annotations, attachments, metadata, or hidden objects are copied into the exported document. The code even rounds each box outwards to whole pixels, so a fractional edge can't leave half a character visible. The cost is that a redacted file has no selectable text at all. That's the right trade for redaction and the wrong one for merging. Knowing which job you're doing tells you which tool to reach for. ## The user's device becomes your server Moving work into the browser removes the upload, but the work itself doesn't disappear. It lands on whatever device the person happens to have, which might be a new laptop or a three-year-old phone with twenty tabs open. Memory is where this shows up first, and file size is a poor guide to it. A canvas stores four bytes per pixel: red, green, blue, and transparency. An A4 page measures 595 × 842 points, so rendering it at twice that size, which is what FreePDF's redaction does to keep text crisp, creates a canvas of 1190 × 1684 pixels. That's about 2 million pixels, or 8 MB, for one page. A compressed 3 MB scan with 40 pages can turn into hundreds of megabytes if every page is decoded and kept around at once. Browsers also put hard limits on canvas size, and phones are stricter. [MDN notes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/canvas#maximum_canvas_size) that iOS devices limit a canvas to 4,096 × 4,096 pixels, and that going over the limit doesn't throw an error. The canvas simply stops drawing. A large architectural drawing rendered at high resolution could come out blank, with no explanation. FreePDF guards against both problems with one small function in [`render.ts`](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/pdf/render.ts). Before thumbnails, compression, redaction, OCR, or image export render a page, they ask it for a scale that keeps the canvas under 12 million pixels and 8,192 pixels on each side (input validation removed here): ```ts export function boundedScale(width: number, height: number, scale: number, maxPixels = 12_000_000) { return Math.min(scale, Math.sqrt(maxPixels / (width * height)), 8192 / width, 8192 / height); } ``` That caps any single canvas at about 48 MB of pixel data, whatever page size the PDF claims. The second habit is releasing memory the moment a page is finished. Each thumbnail is rendered, converted to a small JPEG, and then its canvas is shrunk to nothing: ```ts try { await page.render({ canvas, viewport }).promise; return canvas.toDataURL("image/jpeg", 0.7); } finally { canvas.width = canvas.height = 0; page.cleanup(); } ``` Setting a canvas's width and height to zero lets the browser drop its pixel buffer right away, instead of waiting for garbage collection to notice the element is unused. `page.cleanup()` asks PDF.js to free that page's decoded resources. The `finally` block makes both happen even when rendering fails, which is exactly when a leak would otherwise slip through. A small helper called `withPdf` applies the same rule to whole documents: it always destroys the PDF.js loading task when an operation ends, so even a password error or a corrupt file releases PDF.js's worker. Workers deserve an honest description, too. They keep the interface responsive by moving computation off the main thread, and transfers avoid extra copies. They don't give you more memory. A merge in a worker uses the same RAM as a merge on the page; it just doesn't freeze the page while it runs. They do make cancellation clean. If you leave the page during a merge, the page aborts the job and FreePDF terminates the worker, and everything the worker was holding is released with it. ## What still uses the network Being precise about the privacy boundary means listing what does cross the network. > **Figure 5.** FreePDF's architecture as it runs in a browser tab. Code and models arrive over the network; document contents never leave. > > An app window for freepdf.lol. The page on the main thread handles the interface, file picker, progress, splitting, organizing and form filling. Below it, four engines: pdf-lib edits the PDF structure, with merging running in a Web Worker; PDF.js renders pages for thumbnails, redaction and OCR input, keeping each canvas under 12 megapixels; qpdf compiled to WebAssembly handles protect, unlock and repair; tesseract.js reads text from scanned pages. Output goes to IndexedDB as Recent files, cleared after an hour away, and to a download through a temporary blob URL revoked after ten seconds. Coming in from the network: app code, the PDF.js worker, qpdf.wasm and OCR language data. Going out: document contents, never. The application itself is downloaded: HTML, JavaScript, styles, and the PDF.js worker script. The Protect, Unlock, and Repair tools use [qpdf](https://qpdf.readthedocs.io/) compiled to WebAssembly, which is loaded from FreePDF's own domain the first time you use one of them. OCR, which reads text from scanned pages, uses [tesseract.js](https://github.com/naptha/tesseract.js). By default, tesseract.js downloads its recognition engine and language data from the jsDelivr CDN on first use. The scanned page is still recognized on your device. What travels is the model, not your document. Anything that reports on what happened during a session deserves the same scrutiny. An error report that includes a line of extracted text quietly moves document content off the device, even though the file itself never moved. qpdf normally prints diagnostic output, and FreePDF's [wrapper](https://github.com/pranavv00/freepdf.lol/blob/main/src/lib/pdf/qpdf.ts) silences it on purpose, with a comment explaining why: ```ts const engine = await factory({ locateFile: (path) => path.endsWith(".wasm") ? "/qpdf/qpdf.wasm" : path, // Native diagnostics may include user data. Do not log document contents. print: () => {}, printErr: () => {}, }); ``` It's a two-line decision, but details like this decide whether a privacy promise is actually true. ## When the browser is the wrong place Processing documents in the browser isn't the right answer for every job. It struggles when the input is bigger than the device can comfortably hold, like a 2 GB scan on a phone. It's a poor fit for batch jobs that process thousands of files on a schedule, or for teams that need an audit trail of who changed which document. High-fidelity conversion of Word or PowerPoint files is hard in a browser too, because matching the original layout needs a full office layout engine. That's why server-side converters often run something like LibreOffice behind the scenes. And results can differ slightly between browsers, while a server gives you one environment to test. In those cases, a server can be the honest choice, as long as it's described honestly: what gets uploaded, where it's stored, and when it's deleted. The mistake isn't using a server. It's using one by default for a task the person's own device could have handled. ## Lessons for other browser-based tools FreePDF works with PDFs, but the same reasoning applies to image editors, spreadsheet converters, audio trimmers, and any tool that takes a personal file and returns a changed one. Write the privacy boundary as one precise sentence, such as "document contents never leave the device", and then check it in the Network tab rather than trusting it. Choose libraries by the job: rendering to show a file, structural editing to preserve one, and rebuilding from pixels only when you mean to destroy what's underneath. Budget memory per page, not per file. Release canvases, documents, and workers in `finally` blocks, because failures are where leaks hide. And decide how long local data should live, because "stored on your device" still needs an expiry date. The browser can handle far more document work than most people expect. The hard part is making "local" mean something specific, and then keeping that promise when files are large, devices are small, and an operation fails halfway through. If that last part interests you, [Thinking in Systems, Beyond the Framework](/blog/thinking-in-systems) looks at the same kinds of failures from the network's side.