DEV Community

Cover image for 150 file tools, zero uploads: running ffmpeg, OCR and PDF editing in the browser
Fileora
Fileora

Posted on

150 file tools, zero uploads: running ffmpeg, OCR and PDF editing in the browser

Most "free online PDF tool" sites work the same way: you upload a file, a server processes it, you download the result. That is fine for a meme. It is less fine for a passport scan, a signed contract or a bank statement.

We built Fileora the other way round. It has 150 tools for PDFs, images, video, audio and text, and no backend at all. Every file is opened, processed and saved by the browser on the user's own device. There is no upload endpoint to secure, because there is no upload.

This post is about what that takes in practice: the parts that were easy, the parts that were not, and the limits you should know about before trying the same thing.

The stack

  • Next.js (App Router), statically generated, hosted on a free tier
  • pdf-lib to edit PDF structure, pdf.js to render pages
  • ffmpeg.wasm for video and audio
  • tesseract.js for OCR
  • qpdf compiled to WebAssembly for PDF encryption
  • The Canvas API for everything image-related
  • fflate for ZIP downloads

None of that is exotic. The work is in how you load it and how you keep a phone from running out of memory.

Rule 1: nothing heavy loads until someone clicks

A tool page has to be a normal, fast web page. The ffmpeg core alone is about 31 MB. If that loaded with the page, nobody would stay long enough to use it.

So each tool is its own chunk, and every heavy library is imported inside the handler that needs it, never at the top of a file:

let loading: Promise<FFmpeg> | null = null

export function loadFFmpeg(onProgress = () => {}): Promise<FFmpeg> {
  loading ??= (async () => {
    const { FFmpeg } = await import("@ffmpeg/ffmpeg")
    const ffmpeg = new FFmpeg()
    const wasmURL = await fetchWithProgress("/vendor/ffmpeg/ffmpeg-core.wasm", onProgress)
    await ffmpeg.load({ classWorkerURL, coreURL, wasmURL })
    URL.revokeObjectURL(wasmURL)
    return ffmpeg
  })().catch((error) => {
    loading = null // allow a retry after a failed download
    throw error
  })
  return loading
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here.

Show the download. A 31 MB fetch with a spinner looks like a frozen page. We read the response body with a stream reader, count the bytes, and show a real progress bar. After the first run the core comes from the browser cache.

Reset the promise on failure. If you cache a rejected promise, one dropped connection breaks the tool until the page is reloaded.

Rule 2: single-threaded ffmpeg, on purpose

ffmpeg.wasm has a multi-threaded core that is noticeably faster. It needs SharedArrayBuffer, and browsers only expose that on cross-origin isolated pages, which means sending Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.

Those headers also block any third-party resource that does not opt in. For a site that plans to pay its bills with ads, that is a non-starter. We use the single-threaded core and accept slower encodes.

One more ffmpeg lesson: the progress value it reports is unreliable for many inputs. We parse the Duration: line from the log output once, then divide the time field (which is in microseconds) by it. That gives a progress bar that actually reaches 100%.

Rule 3: self-host every runtime file

Workers, WebAssembly binaries, OCR language data and fonts are all served from our own domain. A small script copies them out of node_modules into public/vendor before each build:

const files = [
  [path.join(pkgDir("pdfjs-dist"), "build", "pdf.worker.min.mjs"), "pdf.worker.min.mjs"],
  ...["ffmpeg-core.js", "ffmpeg-core.wasm"].map((f) => [path.join(pkgDir("@ffmpeg/core"), "dist", "esm", f), `ffmpeg/${f}`]),
  // tesseract worker, cores and language data, qpdf, fonts…
]
Enter fullscreen mode Exit fullscreen mode

This does two things. The worker file always matches the installed library version, which removes a whole class of "works locally, breaks in production" bugs. And the privacy claim stays honest: processing a file does not trigger a request to someone else's CDN.

Images: hitting an exact file size with canvas

One of the most common jobs is "make this photo under 50 KB", because application forms love that rule. Canvas gives you toBlob(callback, type, quality) but no way to ask for a size. So we search for it.

async function bestUnderTarget(canvas, format, targetBytes) {
  const floor = await canvasToBlob(canvas, format, MIN_QUALITY)
  if (floor.size > targetBytes) return null
  let best = floor, lo = MIN_QUALITY, hi = MAX_QUALITY
  for (let i = 0; i < 7; i++) {
    const mid = (lo + hi) / 2
    const blob = await canvasToBlob(canvas, format, mid)
    if (blob.size <= targetBytes) { best = blob; lo = mid } else { hi = mid }
  }
  return best
}
Enter fullscreen mode Exit fullscreen mode

Seven steps of binary search over quality gets very close to the best result that fits. If even the lowest quality is too big, we step down a ladder of scales (85%, 70%, 55%…) and search again at each size. If the original already fits, we return it untouched rather than making it worse.

Three canvas traps we hit along the way:

  • Silent PNG fallback. Ask an older browser for WebP and it may hand you a PNG without complaining. Check blob.type against what you asked for.
  • Black backgrounds. JPEG has no alpha channel. Fill the canvas with white before drawing, or transparent logos come out on black.
  • Memory on mobile Safari. Setting canvas.width = 0 when you are done releases the bitmap memory promptly. Without it, a batch of large photos can kill the tab.

PDFs: three libraries, three jobs

pdf-lib edits structure. Merging copies pages from one document into another as they are, so text stays selectable and nothing is re-compressed.

pdf.js renders. Anything that needs to show a page (signing, cropping, converting to an image) goes through it.

Compression uses both, in two modes. The gentle mode walks the PDF's objects, finds embedded images in colour spaces it can safely convert, and re-encodes only those, leaving text and vectors alone. The aggressive mode renders each page to an image, which is what helps with scans. In both modes, if the result is not smaller, the user keeps the original.

Encryption was the surprise. pdf-lib cannot write an encrypted PDF. We compiled nothing ourselves: qpdf already exists as a WebAssembly build. Each lock or unlock job runs in a fresh worker that is terminated afterwards, so its memory is returned immediately:

const worker = new Worker(new URL("./security.worker.ts", import.meta.url), { type: "module" })
worker.postMessage(job, [job.file]) // transfer the ArrayBuffer, don't copy it
Enter fullscreen mode Exit fullscreen mode

Transferring the buffer instead of copying it matters when the file is 80 MB and the device is a phone.

OCR: tell people what they are about to download

tesseract.js works well in a browser, but it needs an engine (about 4 MB) plus a data file per language (1 to 3 MB each). We ship five languages and show the first-run download size before the user starts, so nobody on mobile data is surprised.

The biggest quality win was not a model setting. Tesseract reads best when letters are around 30 pixels tall, and most screenshots are far below that. We upscale small images by up to 3× before recognition and cap very large photos at 5000 pixels on the longest side. Small cropped screenshots read much better this way.

Where browser-only hurts

It would be dishonest to stop at the wins.

  • Everything lives in memory. There is no streaming to disk. We warn above 100 MB, and a long video on an old phone can still fail.
  • Single-threaded video is slow. Trimming is quick; re-encoding a long clip is a wait.
  • First use costs a download. 31 MB for ffmpeg is fine on Wi-Fi and unfriendly on a metered connection.
  • Browsers differ. Canvas size limits, WebP encoding and HEIC decoding all vary. For HEIC we fall back to a libheif build only when the browser cannot decode the format itself.
  • You cannot fix a user's file for them. With no server and no logs, a bug report is "it didn't work". Error messages have to be specific, because they are the only diagnostics you get.

Was it worth it?

For this kind of product, yes. There is no processing bill that grows with usage, no file storage to secure or to delete, and the privacy promise is something a user can verify in the network tab instead of taking on trust.

The cost is that the hard problems move to the client: memory, download size and browser differences. If your users have small files and modern browsers, the trade is easy. If they have 2 GB videos, it is not.

You can try the tools at fileora.tools, and the full list is on GitHub: free-browser-tools.

If you have built something similar, we would like to hear how you handled large files. That is the part we are least happy with.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.