When you build a privacy-first tool like ZeroCloudPDF, you make a strict architectural choice: no server.
Not a single byte of the user's file is transmitted over the network. Everything happens in the browser tab using pure JavaScript and the Canvas API. But when you remove the server, you also remove the server's RAM. The browser's memory becomes your only bottleneck.
In this article, we will look at the technical realities of processing large files entirely client-side, and the specific memory management techniques required to prevent the browser tab from crashing. (You can see this architecture in action on our Image to PDF tool, and review the underlying architectural decisions in our public ADR repository).
The Base64 Trap
The most common mistake developers make when handling files in the browser is using FileReader.readAsDataURL().
When you read a 10MB JPEG into a Base64 string, the V8 JavaScript engine allocates a massive contiguous block of memory for that string. Worse, Base64 encoding increases the raw data size by exactly 33%. You are taking a 10MB file, turning it into a 13.3MB string, and holding it in the heap.
If a user uploads five high-resolution images, you have just allocated over 60MB of strings in the main thread. On mobile browsers with strict memory limits, this is often the exact moment the tab silently crashes.
The Canvas API Memory Spike
To convert an image to a PDF, we must draw it onto an HTML <canvas>.
This introduces a second, often larger memory spike. When you draw a 4000x3000 pixel image onto a canvas, the browser allocates an uncompressed bitmap in memory. The formula is simple: width × height × 4 bytes (RGBA).
For a 12-megapixel image, that is 48MB of raw bitmap data allocated per image. If you are processing a multi-page document, these bitmaps can quickly consume hundreds of megabytes of RAM.
The Architecture: Blobs and Object URLs
To mitigate this, we must avoid strings at all costs and rely on Blob objects.
Instead of reading the file into a Base64 string, we use URL.createObjectURL(file). This does not read the file into the JavaScript heap. Instead, it creates a temporary URL in the browser's memory space that points directly to the binary data.
// Bad: Reads file into JS heap as a massive string
const reader = new FileReader();
reader.readAsDataURL(file);
// Good: Creates a pointer to the binary data in browser memory
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.src = objectUrl;
Once the image is drawn to the canvas and exported, we must explicitly free that memory. The browser's garbage collector does not automatically revoke object URLs. If you don't revoke them, you will leak memory with every file conversion.
// Always revoke the object URL to free the binary reference
URL.revokeObjectURL(objectUrl);
Passing Data to jsPDF Without Strings
Finally, we must embed the canvas data into the PDF.
Many tutorials suggest using canvas.toDataURL('image/jpeg') to get a Base64 string, and then passing that string to a PDF library. This forces the browser to allocate yet another massive string.
Instead, modern PDF libraries like jsPDF allow you to pass raw binary data. We use canvas.toBlob() to get a Blob object, convert it to an ArrayBuffer, and pass that directly into the PDF generation function.
canvas.toBlob((blob) => {
blob.arrayBuffer().then((buffer) => {
// Pass the raw Uint8Array directly to jsPDF
// No Base64 strings are ever created in the JS heap
const uint8Array = new Uint8Array(buffer);
doc.addImage(uint8Array, 'JPEG', 0, 0, width, height);
});
}, 'image/jpeg', 0.9);
By keeping the data in binary format (Uint8Array) rather than converting it to strings, we drastically reduce the pressure on the V8 garbage collector.
The 100MB Test: Handling Batch Conversions
Handling one image is easy. The real test of a browser-native PDF tool is batch processing. What happens when a user uploads 30 high-res photos (totaling over 100MB) to merge into a single PDF?
If you load all 30 object URLs and canvases simultaneously, the tab will hit the browser's memory limit and crash. The solution is a sequential processing queue.
We process the images one by one using an async/await loop, ensuring that the memory for each image is completely obliterated before the next one begins.
async function processBatchSequentially(files, doc) {
for (const file of files) {
const objectUrl = URL.createObjectURL(file);
const img = await loadImage(objectUrl);
// Draw to canvas, add to PDF document...
drawImageToCanvasAndPDF(img, doc);
// CRITICAL: Free memory before the next iteration
URL.revokeObjectURL(objectUrl);
// Clearing the canvas dimensions explicitly frees the bitmap memory
canvas.width = 0;
canvas.height = 0;
}
doc.save('merged-document.pdf');
}
By forcing the garbage collector's hand and explicitly zeroing out the canvas dimensions, we keep the peak memory footprint limited to just one image at a time, allowing us to process hundreds of megabytes of data without crashing the tab.
The Trade-off of Privacy
Building a zero-server application is not just a privacy decision; it is an architectural trade-off.
When you use a server-based tool, the heavy lifting of memory allocation, file parsing, and garbage collection happens on a machine with 64GB of RAM. When you build a client-side tool, you are asking the user's mobile browser to do all of that work with a fraction of the resources.
But this trade-off is exactly what guarantees privacy. A browser-native converter cannot leak what it never receives. By mastering browser memory management—using Blobs, avoiding Base64, aggressively revoking object URLs, and processing batches sequentially—we can process massive, sensitive documents entirely on the user's device, safely and efficiently.
Top comments (0)