Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 5 min read

I run 38 PDF tools with no server. Here is where the work went instead.

When you merge two PDFs on most websites, your files go to someone's server, get processed there, and come back. I built PDFRoll the other way around: 38 tools, static pages, and nothing that processes a file anywhere bu

When you merge two PDFs on most websites, your files go to someone's server, get processed there, and come back. I built PDFRoll the other way around: 38 tools, static pages, and nothing that processes a file anywhere but in your own browser tab.

That sounds like a privacy feature, and it is. But it is mostly an architecture decision, and it moved the hard problems to places I did not expect. This post is about those places.

What "no server" means here

The site is static HTML on a CDN. The PDF work is done by libraries shipped to the browser:

  • pdf-lib to create and rewrite PDFs
  • PDF.js to read and render them
  • qpdf compiled to WebAssembly for encryption and password removal
  • Tesseract (WebAssembly again) for OCR
  • libheif for iPhone photos, plus a few converters for Word, Excel and PowerPoint files

The only thing a visitor's browser ever sends to my side is a counter. More on that below, because what it is allowed to count turned out to matter.

Problem 1: you can't ship all of that up front

Those libraries add up to megabytes. If a page loaded them all, the person who came to rotate one page would pay for an OCR engine.

So nothing heavy loads with the page. The core trio (pdf-lib, PDF.js, a zip library) loads on the visitor's first gesture: the first pointer press, key press, or file dragged over the window.

function preloadCore() {
  const events = ['pointerdown', 'touchstart', 'keydown', 'dragenter', 'focusin'];
  const go = () => {
    events.forEach((e) => removeEventListener(e, go, true));
    loadCore().catch(() => {});
  };
  events.forEach((e) => addEventListener(e, go, { capture: true, passive: true }));
}

The page stays light for crawlers and for people who only read. By the time someone has picked a file in the dialog, the libraries are there. Everything else (OCR, qpdf, the Office converters) loads only when its tool actually runs.

Problem 2: "compress" is two different jobs

On a server you would hand the file to Ghostscript and be done. In a browser there is no Ghostscript, so I had to decide what compression even means.

There are two honest options:

  1. Lossless rewrite. Copy the pages into a fresh document, drop unused objects, pack the rest into compressed object streams. Text stays text. The gain is often small.
  2. Rasterize. Render each page to a canvas, encode it as JPEG, and build a new PDF from the images. The gain is large, and the text is no longer selectable.

Neither one is right every time, so the tool runs both and compares:

const raster = await out.save();
// At similar weight, prefer lossless: it keeps the text.
if (lossless && lossless.length <= raster.length * 1.15) {
  return { bytes: lossless, method: 'lossless' };
}
return { bytes: raster, method: 'raster' };

That 15% margin is the whole policy. If keeping real text costs at most 15% more bytes, keep the text.

A side effect I like: for files up to 30 pages, the tool computes all three compression levels as soon as the file is chosen and shows the resulting size next to each option. The results are cached, so clicking "Compress" after that is instant. A server would charge me for those speculative runs. Here they cost the visitor a few seconds of idle CPU.

Problem 3: the limit moved to the visitor's device

This is the real price of the design. My costs are close to zero, so there is no file size cap and no daily quota. But the ceiling did not disappear. It moved to the RAM of whoever is using the site.

Rendering a page to a canvas takes uncompressed pixels. A long scanned document on a five-year-old phone can run out of memory, and the browser does not fail politely. You get a RangeError, or "array buffer allocation failed", or sometimes a call stack overflow from deep inside a library.

So every tool runs through one error classifier, and memory failures get their own message:

const MEMORY = /out of memory|allocation failed|array buffer allocation|invalid array length|maximum call stack/i;

if (MEMORY.test(msg) || err instanceof RangeError) {
  return { code: 'memoire', message: T('This file is too heavy for this device\'s memory. Try a smaller file, or a computer.') };
}

It is not a fix. It is telling the person the truth about why it failed and what to do next, which is the best I can do when the machine is not mine.

Speed has the same shape. In my first week of measurements, 4 of the 10 compressions that finished took more than 30 seconds. That is a tiny sample and some of those runs were my own tests, but it is enough to know where the next work is: moving the rendering loop into a worker.

Problem 4: measuring without seeing anything

With a server, you learn what breaks by reading logs. I have no logs, and the entire promise of the site is that I never see the files.

But I still needed to know whether people who start a tool actually finish. So one small endpoint accepts a tiny JSON event: which tool, whether it started, finished or failed, a duration bucket, the page language, and the referring hostname. No cookie, no visitor ID, counters aggregated per day.

The interesting constraint came with failures. An error message can contain a file name. So the raw message never leaves the browser. The classifier above maps every failure to one code from a fixed list (memory, password-protected, damaged file, and a few more), and the server keeps a whitelist:

const CAUSES = new Set(['memoire', 'protege', 'abime', 'outil', 'autre', /* ... */]);
const cause = CAUSES.has(body.code) ? body.code : '';

Anything that is not on the list is dropped. The same function that picks the message shown to the visitor also picks the code that gets counted, so the two can't drift apart.

What I would tell someone starting this

  • Static plus WebAssembly goes much further than I assumed. Encryption, OCR and Office conversion all run fine in a tab.
  • Your scaling problem becomes a support problem. You will never get paged at night. You will get "it froze on my phone", and you can't reproduce it.
  • Decide early what you refuse to know. It is much easier to design measurement around "the file name never leaves the page" on day one than to retrofit it.

If you want to check the claim rather than take my word for it, open the network tab on the compressor and run a file through it. You will see the libraries arrive, and then no request that carries your file: only a small counter and the page analytics beacon.

I'm curious how others handle the memory ceiling. If you have shipped heavy canvas work to low-end phones, what actually helped?

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.